Create custom question types and extend the Formulus Formplayer with specialized input fields.
:::info Production Ready The ODE extension system allows developers to package custom question types that work seamlessly with the form system. Extensions are deployed via app bundles and automatically available to all users. :::
The extension system enables you to:
format: sub-observation):::tip Built-in Formplayer control Sub-observations are rendered by Formplayer itself. You declare them on the parent form’s JSON Schema. :::
Use sub-observations when related answers should live inside the parent observation as an embedded JSON array of child payloads, instead of separate top-level observations per child.
type: "array") with "format": "sub-observation" plus the configuration keys below.openFormplayer with { subObservationMode: true }. The nested session returns child JSON without creating a separate synced observation for each completion.| Property | Required | Description |
|---|---|---|
format |
yes | Must be "sub-observation". |
linkedForm |
yes | Child form type opened for add/edit (non-empty string). |
parentKey |
optional | When set, field name written on new child payloads linking back to the parent (for example a foreign key). When omitted, embedded repeats rely on data already nested in the parent JSON — typical when the child form does not need an injected parent id. |
parentValuePath |
recommended when parentKey is set |
Dot path into current parent form data for that key’s value (falls back to parent observationId when absent). |
columns |
optional | { key, label }[] entries for the on-screen summary list; if omitted, displayField drives a single summary column. |
displayField |
optional | Fallback field key for the summary column (default observationId). |
itemLabel |
optional | Singular name for each embedded item (for example "room"). When set, the add button shows + Add {itemLabel}, the empty table shows No {itemLabel}, and delete confirmations fall back to this {itemLabel}. When omitted, legacy copy is unchanged (+ Add observation, etc.). |
orderBy |
optional | Sort embedded items by field: string field name or { key, direction } (asc / desc). Without key, sorts by createdAt descending when present on payloads. |
allowDelete |
optional | Default true. |
subObservationInitValues |
optional | Map merged into initial params when adding a new embedded child. Values support templates ,, or `` into parent data. |
subObservationEditInitValues |
optional | Map merged on top of the saved child payload when opening an existing embedded item for edit—useful when parent-derived fields must be refreshed each time (often omitted). |
skipFinalize |
optional | When true, the nested child form omits the Finalize page; Done on the last content page runs the same submit path as Finalize. The child is still validated against its own schema.json (AJV + that form’s custom validators) before formData is returned to the parent. Formulus also skips GPS beginObservationSession() and suppresses the success modal for this fast path. Can be set on the schema property or passed via openFormplayer(..., { skipFinalize: true }). |
openFormplayer options (custom apps): { subObservationMode?, skipFinalize?, skipDraftSelection? }. Use skipDraftSelection: true on root forms when the custom app orchestrates the session and must not show the draft picker (for example headless follow-up after persistObservation). Sub-observation sessions never offer the draft picker.
UI schema override: On the parent ui.json Control, options.addButtonLabel sets the full add-button text (JSON Forms array convention). It takes precedence over itemLabel when both are set — useful for localized phrasing (for example "+ Adicionar quarto").
skipFinalizeskipFinalize does not defer validation to the root form. Each nested session is a separate Formplayer instance with its own ui.json and schema:
| When | What validates |
|---|---|
Child Done / submit (skipFinalize or Finalize page) |
Child form only — required fields, AJV, options.customValidators on that form |
| Parent data change / parent Finalize | Parent form — including validators on embedded arrays at the parent level |
On success, SubObservationQuestionRenderer merges result.formData into the parent array and closes the child modal immediately. Parent-level logic (denormalized indexes, cross-row rules, global sequence numbers) does not run inside the child session unless you duplicate it there or pass context in (see below).
Custom validators run in the active Formplayer session only. For a multi-level embedded tree (for example household → rooms[] → beds[] → persons[]):
rooms control runs when root data changes — not when the enumerator adds a bed inside an open room sub-form.skipFinalize).scope: "household" | "quarto" | "cama") so one validator module can serve multiple form types.Authoring checklist for auto-numbering embedded rows:
bed_num, person_num, …).subObservationInitValues / subObservationEditInitValues (single-token templates preserve JSON types), or wait for platform parent context (below). Strip ephemeral snapshot fields on root finalize so they are not persisted.See Custom validators and Parent context across nesting levels.
Nested openFormplayer sessions receive only the current row as core.data, not the full parent observation. That limits cross-sibling validation, global numbering, and extension helpers that need ancestor fields.
Today (workaround): Copy needed parent slices into child data with flat init templates, for example "household_rooms": "" on subObservationInitValues. Formplayer resolves top-level string templates only — not nested object maps. Mark snapshot fields readOnly and remove them in a root-level validator before persist. Distinct from format: "form_context" / params.context, which are better for session metadata than large tree copies.
Proposed (not yet in ODE): subObservationContext — read-only parent snapshot resolved at open time, exposed to validators/extensions, not validated against the child schema and not merged into persisted child JSON. Until then, use init templates or duplicate validators per level.
Example property on the parent schema:
{
"linked_visits": {
"type": "array",
"format": "sub-observation",
"title": "Visits",
"linkedForm": "visit",
"parentKey": "household_id",
"parentValuePath": "hh_id",
"displayField": "visit_date",
"allowDelete": true,
"subObservationInitValues": {
"household_id": ""
}
}
}
:::note Types in legacy forms
Some forms use type: ["array", "string"] with "format": "sub-observation" for migration compatibility; Formplayer activates the control whenever format matches.
:::
validators/)Bundle custom validators alongside custom question types. Register them in the app manifest (validators/<name>/index.js); reference them from ui.json control options.customValidators.
{
"type": "Control",
"scope": "#/properties/quartos",
"options": {
"customValidators": [
{ "name": "assignRepeatPositions", "config": { "quartosField": "quartos" } }
]
}
}
Mutating validators: A validator may update data in place (for example auto-numbering embedded sub-observation rows or rebuilding a denormalized index array). Formplayer detects mutations after each change and before finalize, then refreshes form state so summary tables and dependent fields update immediately. Return validation errors in the usual way; returning patches is not required.
Per-session scope: Mutations apply to the current form session. Nested sub-observations need validators on each level where rows are added, or a parent snapshot field (see Parent context across nesting levels). Root-only validators are not enough for deep embedded trees.
{
"type": "Control",
"scope": "#/properties/beds",
"options": {
"customValidators": [
{
"name": "assignRepeatPositions",
"config": { "scope": "room", "bedsField": "beds" }
}
]
}
}
See also Form specifications and Formplayer.
A custom question type consists of:
Example: Custom Phone Number Input
// phone-number-type.tsx
import React from 'react';
import { Control } from 'react-hook-form';
interface PhoneNumberProps {
value: string;
onChange: (value: string) => void;
format: 'intl' | 'local'; // From UI Schema
country?: string; // From UI Schema
required?: boolean;
error?: string;
}
export const PhoneNumberControl: React.FC<PhoneNumberProps> = ({
value,
onChange,
format,
country,
error,
required
}) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target.value;
// Custom formatting logic
const cleaned = input.replace(/\D/g, '');
const formatted = formatPhoneNumber(cleaned, format, country);
onChange(formatted);
};
return (
<div>
<input
type="tel"
value={value}
onChange={handleChange}
placeholder={getPlaceholder(format, country)}
required={required}
aria-invalid={!!error}
/>
{error && <span className="error">{error}</span>}
</div>
);
};
// Helper functions
function formatPhoneNumber(
digits: string,
format: 'intl' | 'local',
country?: string
): string {
if (format === 'intl') {
return `+${digits}`; // International format
}
// Local format based on country
if (country === 'UG') {
return digits.replace(/(\d{3})(\d{2})(\d{6})/, '+256$2 $3');
}
return digits;
}
function getPlaceholder(format: 'intl' | 'local', country?: string): string {
if (format === 'intl') return '+256 701 234567';
if (country === 'UG') return '0701 234567';
return '(Enter phone number)';
}
Define the custom type in your form schema:
{
"schema": {
"type": "object",
"properties": {
"phone": {
"type": "string",
"title": "Phone Number",
"x-custom": {
"type": "phone-number",
"format": "intl",
"country": "UG"
}
}
}
},
"uischema": {
"type": "VerticalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/phone"
}
]
}
}
Extensions are registered when the app initializes using the extension system:
// In Formulus initialization or custom app setup
import { registerExtension } from '@ode/formulus-extensions';
import { PhoneNumberControl } from './phone-number-type';
registerExtension({
name: 'phone-number',
renderer: PhoneNumberControl,
schema: {
type: 'string',
x-custom: {
type: 'phone-number'
}
},
validation: {
pattern: '^\\+?\\d{6,15}$',
minLength: 6,
maxLength: 15
},
metadata: {
displayName: 'Phone Number',
description: 'International or local phone number input',
version: '1.0.0'
}
});
New input component for collecting specific data types:
interface QuestionTypeExtension {
name: string; // Unique type ID
renderer: React.ComponentType; // React component
supportedFormats?: string[]; // Optional format variants
validation?: ValidationSchema; // Validation rules
metadata: ExtensionMetadata;
}
Examples:
Custom functions for validation, calculation, or data processing:
interface BusinessLogicExtension {
name: string;
description: string;
functions: {
[key: string]: (params: any) => any;
};
metadata: ExtensionMetadata;
}
// Example: Custom validation function
registerLogicExtension({
name: 'advanced-validations',
functions: {
validateHouseholdStructure: (household) => {
// Validate household relationships
const adults = household.members.filter(m => m.age >= 18);
return adults.length > 0;
},
calculateHouseholdSize: (household) => {
return household.members.length;
}
}
});
// Use in form
{
"type": "object",
"properties": {
"members": {
"type": "array",
"x-validation": {
"function": "validateHouseholdStructure"
}
}
}
}
Augment observations with computed or retrieved data:
interface DataEnhancementExtension {
name: string;
enhancers: {
[key: string]: (obs: Observation) => Promise<any>;
};
}
// Example: Fetch location name from coordinates
registerDataEnhancer({
name: 'location-enrichment',
enhancers: {
reverseGeocode: async (observation) => {
const { lat, lng } = observation.data.location;
const response = await fetch(
`https://api.example.com/reverse?lat=${lat}&lng=${lng}`
);
return {
location_name: response.locality,
location_admin: response.admin2,
location_country: response.country
};
}
}
});
Extensions are distributed as part of the app bundle:
app-bundle.zip
├── forms/
│ └── *.json # Form definitions
├── question_types/
│ ├── custom-phone-number.js
│ ├── custom-map-field.js
│ └── custom-signature.js
├── logic/
│ ├── household-validations.js
│ └── calculations.js
├── styles/
│ └── extensions.css # Custom CSS for extensions
└── metadata.json
Define extension metadata in metadata.json:
{
"version": "1.2.0",
"description": "Custom question types for household surveys",
"forms": ["household", "hh_person", "hh_follow_up"],
"extensions": [
{
"name": "phone-number",
"type": "question-type",
"description": "International phone number input",
"version": "1.0.0"
},
{
"name": "location-detail",
"type": "question-type",
"description": "GPS with map preview",
"version": "1.1.0"
},
{
"name": "household-validations",
"type": "business-logic",
"description": "Validation rules for household data",
"version": "1.0.0"
}
],
"dependencies": [
"formulus >= 1.0.0",
"formplayer >= 1.0.0"
],
"author": "Your Organization",
"license": "MIT"
}
Use the Synkronus CLI or API to deploy:
synk app-bundle upload path/to/bundle.zip
Or API:
curl -X PUT https://synkronus.example.com/api/v1/app-bundle \
-H "Authorization: Bearer $TOKEN" \
-F "[email protected]"
Maintain multiple versions:
# List versions
synk app-bundle list
# Activate specific version
synk app-bundle activate 1.2.0
# Previous version remains available for older clients
If issues occur:
# Immediately activate previous version
synk app-bundle activate 1.1.0
# Clients will pull updated bundle on next sync
✅ Do:
❌ Don’t:
// time-range-picker.tsx
import React, { useState } from 'react';
interface TimeRange {
start: string; // HH:MM
end: string; // HH:MM
}
interface TimeRangePickerProps {
value: TimeRange;
onChange: (value: TimeRange) => void;
label?: string;
}
export const TimeRangePicker: React.FC<TimeRangePickerProps> = ({
value,
onChange,
label
}) => {
const [startTime, setStartTime] = useState(value?.start || '');
const [endTime, setEndTime] = useState(value?.end || '');
const handleChange = (newStart: string, newEnd: string) => {
setStartTime(newStart);
setEndTime(newEnd);
onChange({ start: newStart, end: newEnd });
};
const isValidRange = !startTime || !endTime || startTime <= endTime;
return (
<div className="time-range-picker">
{label && <label>{label}</label>}
<div className="time-inputs">
<div className="time-field">
<label htmlFor="start">Start Time</label>
<input
id="start"
type="time"
value={startTime}
onChange={(e) => handleChange(e.target.value, endTime)}
/>
</div>
<div className="separator">to</div>
<div className="time-field">
<label htmlFor="end">End Time</label>
<input
id="end"
type="time"
value={endTime}
onChange={(e) => handleChange(startTime, e.target.value)}
/>
</div>
</div>
{!isValidRange && (
<div className="error">End time must be after start time</div>
)}
</div>
);
};
Register it:
registerExtension({
name: 'time-range',
renderer: TimeRangePicker,
metadata: {
displayName: 'Time Range',
description: 'Select start and end times',
version: '1.0.0'
}
});
Use in form:
{
"working_hours": {
"type": "object",
"x-custom": {
"type": "time-range"
},
"properties": {
"start": { "type": "string" },
"end": { "type": "string" }
}
}
}
import { render, screen } from '@testing-library/react';
import { TimeRangePicker } from './time-range-picker';
describe('TimeRangePicker', () => {
it('should accept time range', () => {
const onChange = jest.fn();
render(
<TimeRangePicker
value=
onChange={onChange}
label="Working Hours"
/>
);
const inputs = screen.getAllByRole('textbox');
expect(inputs[0]).toHaveValue('09:00');
expect(inputs[1]).toHaveValue('17:00');
});
it('should validate time range', () => {
const { getByText } = render(
<TimeRangePicker
value=
onChange={() => {}}
/>
);
expect(getByText(/End time must be after start time/)).toBeInTheDocument();
});
});
Test with actual forms and the Formulus environment.