Technical specification for ODE form definitions using JSON schema and JSON Forms.
Forms in ODE are defined using two JSON documents:
Both follow established standards: JSON Schema for data validation and JSON Forms for UI specification.
The schema follows the JSON Schema specification (draft 7). It defines the structure and validation rules for form data.
{
"type": "object",
"properties": {
"fieldName": {
"type": "string",
"title": "Field Label",
"description": "Field description"
}
},
"required": ["fieldName"]
}
| Type | Description | Example |
|---|---|---|
string |
Text input | Names, descriptions, text fields |
integer |
Whole number | Age, count, quantity |
number |
Decimal number | Weight, temperature, measurements |
boolean |
True/false value | Consent, agreement, flags |
array |
List of items | Multiple selections, lists |
object |
Nested object | Complex data structures |
Common validation rules:
{
"type": "string",
"minLength": 5,
"maxLength": 100,
"pattern": "^[A-Za-z]+$",
"format": "email"
}
Available formats:
email: Email address validationdate: Date validation (ISO 8601)date-time: Date and time validationuri: URI validationuuid: UUID validationNumeric constraints:
minimum / maximum: For numbersexclusiveMinimum / exclusiveMaximum: Exclude boundary valuesmultipleOf: Must be multiple of valueString constraints:
minLength / maxLength: String lengthpattern: Regular expression patternDefine allowed values:
{
"type": "string",
"enum": ["option1", "option2", "option3"],
"enumNames": ["Option 1", "Option 2", "Option 3"]
}
The UI schema follows the JSON Forms UI Schema specification. It controls how form fields are presented and organized.
{
"type": "VerticalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/fieldName"
}
]
}
| Layout | Description | Use Case |
|---|---|---|
VerticalLayout |
Fields arranged vertically | Default layout |
HorizontalLayout |
Fields arranged horizontally | Side-by-side fields |
Group |
Group related fields | Logical grouping |
Categorization |
Organize into categories | Complex forms with sections |
{
"type": "Control",
"scope": "#/properties/fieldName",
"label": "Custom Label",
"options": {
"placeholder": "Enter value",
"format": "password"
}
}
Show or hide fields based on other field values:
{
"type": "Control",
"scope": "#/properties/email",
"rule": {
"effect": "SHOW",
"condition": {
"scope": "#/properties/contactMethod",
"schema": {
"const": "email"
}
}
}
}
Question types are specified using the format property in the schema:
{
"type": "string",
"title": "Name",
"format": "text"
}
{
"type": "integer",
"title": "Age",
"minimum": 0,
"maximum": 120
}
{
"type": "string",
"title": "Date",
"format": "date"
}
{
"type": "string",
"title": "Choice",
"enum": ["option1", "option2"],
"enumNames": ["Option 1", "Option 2"]
}
Collect multiple related observations as JSON objects inside one parent observation using "format": "sub-observation". Each embedded item is edited in a nested Formplayer session (openFormplayer with subObservationMode). Only linkedForm is required; parentKey is optional. Optional itemLabel customizes add-button and empty-table copy (see Custom Extensions).
Nested sessions validate the child schema on submit (skipFinalize only skips the Finalize page). Parent-level validators and denormalized fields run in the parent session. For multi-level trees, use validators on each form where rows are added, or parent snapshot init fields — see Nested sessions and custom validators.
See the full schema options and examples in Custom Extensions.
{
"linked_children": {
"type": "array",
"format": "sub-observation",
"title": "Related entries",
"linkedForm": "child_form",
"parentKey": "parent_id",
"parentValuePath": "parent_id",
"displayField": "name",
"subObservationInitValues": {
"parent_id": ""
}
}
}
{
"type": "object",
"format": "photo",
"title": "Profile Photo"
}
{
"type": "string",
"format": "audio",
"title": "Voice Note"
}
{
"type": "string",
"format": "video",
"title": "Instructional Video"
}
{
"type": "string",
"format": "gps",
"title": "Current Location"
}
{
"type": "object",
"format": "signature",
"title": "Customer Signature"
}
{
"type": "string",
"format": "qrcode",
"title": "QR Code Scanner"
}
Generic attachment via document picker. Use type: object with format: select_file (Formplayer stores basename + portable metadata; no inline preview).
{
"type": "object",
"format": "select_file",
"title": "Upload Document"
}
Use format: "likert" for agreement, satisfaction, frequency, importance, likelihood, and numeric rating scales.
:::tip Display and content are independent
There are no topic-specific Likert types (no separate “pain slider” or “satisfaction buttons”). Pick a likert.display for how the scale looks, and set title plus oneOf for what it measures — any display works with any values and labels. Presets are optional shortcuts for common label sets on standard numeric ranges.
:::
Add a field to schema.json, then wire it in ui.json with a standard Control:
schema.json
{
"type": "object",
"properties": {
"satisfaction": {
"type": "integer",
"format": "likert",
"title": "How satisfied are you with the service?",
"oneOf": [
{ "const": 1, "title": "Very dissatisfied" },
{ "const": 2, "title": "Dissatisfied" },
{ "const": 3, "title": "Neutral" },
{ "const": 4, "title": "Satisfied" },
{ "const": 5, "title": "Very satisfied" }
],
"likert": {
"display": "buttons",
"colorMode": "spectrum",
"allowClear": true
}
}
},
"required": ["satisfaction"]
}
ui.json
{
"type": "Control",
"scope": "#/properties/satisfaction"
}
That is enough to render the question. Put display options (likert.display, colorMode, etc.) in schema.json. Use ui.json only when you need per-placement overrides such as layout (orientation) or a different display for the same field on another page.
For multi-locale forms, put the visible question text on Control.label in ui.json (with optional translations) rather than relying on schema.title alone — see Form translations.
The observation stores the selected option’s oneOf[].const — not the label text.
| User selects | Stored value (satisfaction) |
|---|---|
| “Satisfied” | 4 |
Clears answer (when allowClear: true) |
field omitted or null depending on schema |
| “Not applicable” (when configured) | null (or notApplicableValue) |
Example observation payload:
{
"satisfaction": 4
}
In review/finalize mode, Formplayer shows the matching oneOf[].title (e.g. “Satisfied”), or the N/A label when the stored value is null.
likert.display)| Value | Presentation |
|---|---|
buttons |
Equal-width labelled option cells (default) |
radio |
Radio row with labels below (classic survey style) |
slider |
Slider with tick marks, endpoint anchors, and value badge |
numeric |
Compact number cells (NPS style) |
stars |
Star rating with the selected label alongside |
emoji |
One emoji per option (emoji on each oneOf entry) |
All variants share one clean look: neutral outlined option cells, with an accent border and tint on the selected option only. Every option is a touch-friendly target and the layout adapts to phone and tablet widths.
likert.colorMode)| Value | Selected-option accent |
|---|---|
neutral |
Theme primary (default) |
spectrum |
Semantic red → yellow → green by scale position |
stars |
Standard rating gold (used with display: "stars") |
Colour is only ever a secondary cue — selection is always conveyed by the border, tint, and weight as well, so scales remain readable for colour-blind respondents.
When you omit oneOf, set likert.preset to generate standard options automatically:
{
"type": "integer",
"format": "likert",
"title": "How often do you exercise?",
"likert": {
"preset": "frequency",
"display": "buttons",
"colorMode": "neutral"
}
}
| Preset | Stored values | Options (labels) |
|---|---|---|
agreement |
1–5 | Strongly disagree → Strongly agree |
frequency |
1–5 | Never → Always |
satisfaction |
1–5 | Very dissatisfied → Very satisfied |
importance |
1–5 | Not important → Very important |
likelihood |
1–5 | Very unlikely → Very likely |
numeric_0_10 |
0–10 | 0, 1, … 10 |
numeric_1_5 |
1–5 | 1, 2, … 5 |
numeric_1_7 |
1–7 | 1, 2, … 7 |
Presets are a shortcut for common scales. For custom wording or non-standard value ranges, define your own oneOf instead.
| Display | Best for | Label pattern |
|---|---|---|
buttons / radio |
Opinion scales (3–5 options) | Full label per option (oneOf[].title) |
numeric |
NPS, intensity, rating (5+ points) | Numbers in cells; word labels on the first/last oneOf entries |
buttons + endpointLabelsOnly: true |
NPS 0–10 in button form | Digits in cells; endpoint words below |
slider |
Continuous 0–10 ranges | Endpoint word anchors below; value badge always visible |
emoji |
Optional sentiment (low-stakes) | Emoji and text label on every option |
stars |
5-point satisfaction | Star count with the selected label beside it |
Research on survey scales favours numeric scales with verbal endpoint anchors (highest reliability) and fully-labelled word buttons (fastest to answer). Emoji are engaging but can cluster toward the middle and vary by culture, so they always render with a text label and are best reserved for informal contexts.
0–10 slider with endpoint anchors — stores 0 … 10. Change title and the first/last oneOf[].title values to rate anything; the slider UI is the same.
Example — pain intensity:
{
"type": "integer",
"format": "likert",
"title": "Rate your pain level",
"oneOf": [
{ "const": 0, "title": "No pain" },
{ "const": 1, "title": "1" },
{ "const": 2, "title": "2" },
{ "const": 3, "title": "3" },
{ "const": 4, "title": "4" },
{ "const": 5, "title": "5" },
{ "const": 6, "title": "6" },
{ "const": 7, "title": "7" },
{ "const": 8, "title": "8" },
{ "const": 9, "title": "9" },
{ "const": 10, "title": "Worst pain" }
],
"likert": {
"display": "slider",
"colorMode": "spectrum"
}
}
Example — task difficulty (same display, different labels):
{
"type": "integer",
"format": "likert",
"title": "How difficult was this task?",
"oneOf": [
{ "const": 0, "title": "Very easy" },
{ "const": 1, "title": "1" },
{ "const": 2, "title": "2" },
{ "const": 3, "title": "3" },
{ "const": 4, "title": "4" },
{ "const": 5, "title": "5" },
{ "const": 6, "title": "6" },
{ "const": 7, "title": "7" },
{ "const": 8, "title": "8" },
{ "const": 9, "title": "9" },
{ "const": 10, "title": "Extremely hard" }
],
"likert": {
"display": "slider",
"colorMode": "spectrum"
}
}
NPS-style 0–10 (endpoint labels only in button form) — stores 0 … 10:
{
"type": "integer",
"format": "likert",
"title": "How likely are you to recommend us?",
"oneOf": [
{ "const": 0, "title": "Not at all likely" },
{ "const": 1, "title": "1" },
{ "const": 2, "title": "2" },
{ "const": 3, "title": "3" },
{ "const": 4, "title": "4" },
{ "const": 5, "title": "5" },
{ "const": 6, "title": "6" },
{ "const": 7, "title": "7" },
{ "const": 8, "title": "8" },
{ "const": 9, "title": "9" },
{ "const": 10, "title": "Extremely likely" }
],
"likert": {
"display": "buttons",
"colorMode": "spectrum",
"endpointLabelsOnly": true,
"allowClear": true
}
}
Emoji sentiment — stores 1 … 5; emoji is display-only metadata:
{
"type": "integer",
"format": "likert",
"title": "How do you feel about your visit today?",
"oneOf": [
{ "const": 1, "title": "Very bad", "emoji": "😞" },
{ "const": 2, "title": "Bad", "emoji": "😕" },
{ "const": 3, "title": "Okay", "emoji": "😐" },
{ "const": 4, "title": "Good", "emoji": "🙂" },
{ "const": 5, "title": "Great", "emoji": "😄" }
],
"likert": {
"display": "emoji",
"colorMode": "spectrum",
"allowClear": true
}
}
With “Not applicable” — stores null when N/A is chosen; use type: ["integer", "null"]:
{
"type": ["integer", "null"],
"format": "likert",
"title": "How important is this feature to you?",
"oneOf": [
{ "const": 1, "title": "Not important" },
{ "const": 2, "title": "Slightly important" },
{ "const": 3, "title": "Moderately important" },
{ "const": 4, "title": "Important" },
{ "const": 5, "title": "Very important" }
],
"likert": {
"display": "buttons",
"allowClear": true,
"allowNotApplicable": true,
"notApplicableLabel": "Not applicable",
"notApplicableValue": null
}
}
:::note
You only need type: ["integer", "null"] (so the field can hold the N/A value). Formplayer automatically allows the notApplicableValue during validation — you do not need to add a null branch to oneOf yourself, and the N/A choice is never shown twice.
:::
Required field — add the property name to the schema required array:
{
"type": "object",
"properties": {
"satisfaction": { "type": "integer", "format": "likert", "likert": { "preset": "satisfaction" } }
},
"required": ["satisfaction"]
}
You can also mark a control required in ui.json with "options": { "required": true } on that Control.
| Option | Description |
|---|---|
likert.allowClear |
Tapping the selected option again clears it (default true). |
likert.endpointLabelsOnly |
Show word labels only at the endpoints of long numeric scales; omit for 3–4 option scales. |
likert.allowNotApplicable |
Adds a “Not applicable” choice. Use type: ["integer", "null"]; stores the notApplicableValue. |
likert.notApplicableLabel |
Custom label for the N/A option (default “Not applicable”). |
likert.notApplicableValue |
Stored value for N/A (default null). |
Control the arrangement from the UI schema options:
{
"type": "Control",
"scope": "#/properties/satisfaction",
"options": { "orientation": "cols-2" }
}
options.orientation accepts horizontal (default), vertical (stacked), flow (wrap), or cols-2 … cols-5 (a fixed multi-column grid, useful on tablets). Word and radio scales automatically stack to one option per row on narrow phones. options.display overrides likert.display for that control only.
On tablets and desktop, word-label scales render as equal-width cells in an even grid, so long labels (e.g. “Strongly agree”, “Always”) never stretch to a full-width row.
In review/read-only mode the selected answer stays prominent while the other options are de-emphasised.
Question text is translated via Control.label + translations (see Form translations). To translate the scale option labels, mirror the values in the UI schema options.oneOf and provide per-locale overrides — they are matched to the schema options by const:
{
"type": "Control",
"scope": "#/properties/satisfaction",
"label": "How satisfied are you?",
"options": {
"oneOf": [
{ "const": 1, "title": "Very dissatisfied" },
{ "const": 5, "title": "Very satisfied" }
]
},
"translations": {
"pt": {
"label": "Quão satisfeito está?",
"options": {
"oneOf": [
{ "const": 1, "title": "Muito insatisfeito" },
{ "const": 5, "title": "Muito satisfeito" }
]
}
}
}
}
The stored value is unchanged (oneOf[].const). Any option not listed in a locale keeps its schema.json oneOf[].title. The finalize summary uses the schema.json titles.
Use format: "duration" to capture elapsed time. The stored value is always a JSON number of seconds (e.g. 90.5 for one minute thirty-and-a-half seconds).
schema.json
{
"type": "object",
"properties": {
"task_duration": {
"type": "number",
"format": "duration",
"title": "Time to complete the task",
"minimum": 0,
"duration": {
"mode": "stopwatch",
"unit": "seconds",
"precision": 1,
"allowManualEntry": true
}
}
}
}
ui.json
{
"type": "Control",
"scope": "#/properties/task_duration"
}
| Mode | When value is written | Example stored value |
|---|---|---|
stopwatch |
Collector taps Save after timing | 125.3 (seconds) |
countdown |
When countdown completes or is saved | 42.0 |
manual |
On blur / field commit | 300 |
Example observation payload:
{
"task_duration": 125.3
}
The finalize summary shows a human-readable form (e.g. 2 min 5.3 sec). Use minimum: 0 to reject negative durations.
duration.mode |
Presentation |
|---|---|
stopwatch |
Start / Pause / Resume / Reset, then Save to commit |
countdown |
Counts down from duration.countdownFrom seconds |
manual |
A plain numeric seconds field only |
Stopwatch does not write a value while the timer is running. The collector must pause and tap Save — this prevents partial or accidental commits.
Stopwatch with optional manual entry (default pattern):
{
"type": "number",
"format": "duration",
"title": "Time to complete the task",
"minimum": 0,
"duration": {
"mode": "stopwatch",
"unit": "seconds",
"precision": 1,
"allowManualEntry": true
}
}
Countdown from 60 seconds (e.g. breath-hold test):
{
"type": "number",
"format": "duration",
"title": "Hold breath duration",
"minimum": 0,
"duration": {
"mode": "countdown",
"unit": "seconds",
"precision": 1,
"countdownFrom": 60,
"allowManualEntry": false
}
}
Manual entry only (no timer UI):
{
"type": "number",
"format": "duration",
"title": "Enter elapsed time (seconds)",
"minimum": 0,
"duration": {
"mode": "manual",
"unit": "seconds",
"precision": 1
}
}
| Option | Description |
|---|---|
duration.mode |
stopwatch, countdown, or manual (default stopwatch) |
duration.unit |
Display unit; currently only "seconds" |
duration.precision |
Decimal places shown (default 1) |
duration.allowManualEntry |
When true, shows a seconds input alongside stopwatch/countdown |
duration.countdownFrom |
Starting seconds for countdown mode (required for countdown) |
Forms support versioning to allow updates while maintaining compatibility:
schemaType and schemaVersion{
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Full Name",
"minLength": 1,
"maxLength": 100
},
"age": {
"type": "integer",
"title": "Age",
"minimum": 0,
"maximum": 120
},
"email": {
"type": "string",
"title": "Email Address",
"format": "email"
},
"photo": {
"type": "object",
"format": "photo",
"title": "Profile Photo"
}
},
"required": ["name", "age"]
},
"uischema": {
"type": "VerticalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/name"
},
{
"type": "Control",
"scope": "#/properties/age"
},
{
"type": "Control",
"scope": "#/properties/email"
},
{
"type": "Control",
"scope": "#/properties/photo"
}
]
}
}