Skip to main content

Form Specifications

Technical specification for ODE form definitions using JSON schema and JSON Forms.

Overview

Forms in ODE are defined using two JSON documents:

  1. Schema: Defines the data structure and validation rules (JSON Schema)
  2. UI Schema: Defines how the form is presented to users (JSON Forms UI Schema)

Both follow established standards: JSON Schema for data validation and JSON Forms for UI specification.

Schema Format

The schema follows the JSON Schema specification (draft 7). It defines the structure and validation rules for form data.

Basic Structure

{
  "type": "object",
  "properties": {
    "fieldName": {
      "type": "string",
      "title": "Field Label",
      "description": "Field description"
    }
  },
  "required": ["fieldName"]
}

Property Types

TypeDescriptionExample
stringText inputNames, descriptions, text fields
integerWhole numberAge, count, quantity
numberDecimal numberWeight, temperature, measurements
booleanTrue/false valueConsent, agreement, flags
arrayList of itemsMultiple selections, lists
objectNested objectComplex data structures

Validation Rules

Common validation rules:

{
  "type": "string",
  "minLength": 5,
  "maxLength": 100,
  "pattern": "^[A-Za-z]+$",
  "format": "email"
}

Available formats:

  • email: Email address validation
  • date: Date validation (ISO 8601)
  • date-time: Date and time validation
  • uri: URI validation
  • uuid: UUID validation

Numeric constraints:

  • minimum / maximum: For numbers
  • exclusiveMinimum / exclusiveMaximum: Exclude boundary values
  • multipleOf: Must be multiple of value

String constraints:

  • minLength / maxLength: String length
  • pattern: Regular expression pattern

Enumeration

Define allowed values:

{
  "type": "string",
  "enum": ["option1", "option2", "option3"],
  "enumNames": ["Option 1", "Option 2", "Option 3"]
}

UI Schema Format

The UI schema follows the JSON Forms UI Schema specification. It controls how form fields are presented and organized.

Basic Structure

{
  "type": "VerticalLayout",
  "elements": [
    {
      "type": "Control",
      "scope": "#/properties/fieldName"
    }
  ]
}

Layout Types

LayoutDescriptionUse Case
VerticalLayoutFields arranged verticallyDefault layout
HorizontalLayoutFields arranged horizontallySide-by-side fields
GroupGroup related fieldsLogical grouping
CategorizationOrganize into categoriesComplex forms with sections

Control Configuration

{
  "type": "Control",
  "scope": "#/properties/fieldName",
  "label": "Custom Label",
  "options": {
    "placeholder": "Enter value",
    "format": "password"
  }
}

Conditional Display

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

Question types are specified using the format property in the schema:

Text Input

{
  "type": "string",
  "title": "Name",
  "format": "text"
}

Number Input

{
  "type": "integer",
  "title": "Age",
  "minimum": 0,
  "maximum": 120
}

Date and Time

{
  "type": "string",
  "title": "Date",
  "format": "date"
}

Selection

{
  "type": "string",
  "title": "Choice",
  "enum": ["option1", "option2"],
  "enumNames": ["Option 1", "Option 2"]
}

Sub-observations (embedded observations of another form type)

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": "{{parentValue}}"
    }
  }
}

Multimedia Types

Photo

{
  "type": "object",
  "format": "photo",
  "title": "Profile Photo"
}

Audio

{
  "type": "string",
  "format": "audio",
  "title": "Voice Note"
}

Video

{
  "type": "string",
  "format": "video",
  "title": "Instructional Video"
}

GPS

{
  "type": "string",
  "format": "gps",
  "title": "Current Location"
}

Signature

{
  "type": "object",
  "format": "signature",
  "title": "Customer Signature"
}

QR Code

{
  "type": "string",
  "format": "qrcode",
  "title": "QR Code Scanner"
}

File Selection

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"
}

Rating Scales (Likert)

Use format: "likert" for agreement, satisfaction, frequency, importance, likelihood, and numeric rating scales.

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.

Quick start

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.

What gets stored

The observation stores the selected option's oneOf[].const — not the label text.

User selectsStored 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.

Display modes (likert.display)

ValuePresentation
buttonsEqual-width labelled option cells (default)
radioRadio row with labels below (classic survey style)
sliderSlider with tick marks, endpoint anchors, and value badge
numericCompact number cells (NPS style)
starsStar rating with the selected label alongside
emojiOne 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.

Colour (likert.colorMode)

ValueSelected-option accent
neutralTheme primary (default)
spectrumSemantic red → yellow → green by scale position
starsStandard 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.

Presets

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"
  }
}
PresetStored valuesOptions (labels)
agreement1–5Strongly disagree → Strongly agree
frequency1–5Never → Always
satisfaction1–5Very dissatisfied → Very satisfied
importance1–5Not important → Very important
likelihood1–5Very unlikely → Very likely
numeric_0_100–100, 1, … 10
numeric_1_51–51, 2, … 5
numeric_1_71–71, 2, … 7

Presets are a shortcut for common scales. For custom wording or non-standard value ranges, define your own oneOf instead.

Choosing a scale

DisplayBest forLabel pattern
buttons / radioOpinion scales (3–5 options)Full label per option (oneOf[].title)
numericNPS, intensity, rating (5+ points)Numbers in cells; word labels on the first/last oneOf entries
buttons + endpointLabelsOnly: trueNPS 0–10 in button formDigits in cells; endpoint words below
sliderContinuous 0–10 rangesEndpoint word anchors below; value badge always visible
emojiOptional sentiment (low-stakes)Emoji and text label on every option
stars5-point satisfactionStar 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.

Copy-paste recipes

0–10 slider with endpoint anchors — stores 010. 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 010:

{
  "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 15; 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
  }
}

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.

Additional options

OptionDescription
likert.allowClearTapping the selected option again clears it (default true).
likert.endpointLabelsOnlyShow word labels only at the endpoints of long numeric scales; omit for 3–4 option scales.
likert.allowNotApplicableAdds a "Not applicable" choice. Use type: ["integer", "null"]; stores the notApplicableValue.
likert.notApplicableLabelCustom label for the N/A option (default "Not applicable").
likert.notApplicableValueStored value for N/A (default null).

Layout (UI schema)

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-2cols-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.

Translating option labels

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.

Duration / Timer

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).

Quick start

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"
}

What gets stored

ModeWhen value is writtenExample stored value
stopwatchCollector taps Save after timing125.3 (seconds)
countdownWhen countdown completes or is saved42.0
manualOn blur / field commit300

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.

Modes

duration.modePresentation
stopwatchStart / Pause / Resume / Reset, then Save to commit
countdownCounts down from duration.countdownFrom seconds
manualA 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.

Copy-paste recipes

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
  }
}

Duration options

OptionDescription
duration.modestopwatch, countdown, or manual (default stopwatch)
duration.unitDisplay unit; currently only "seconds"
duration.precisionDecimal places shown (default 1)
duration.allowManualEntryWhen true, shows a seconds input alongside stopwatch/countdown
duration.countdownFromStarting seconds for countdown mode (required for countdown)

Form Versioning

Forms support versioning to allow updates while maintaining compatibility:

  • Each form has a schemaType and schemaVersion
  • When editing an observation, the form version used to create it is used
  • New observations use the latest form version

Complete Example

{
  "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"
      }
    ]
  }
}