Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/guides/choice-lists.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,20 @@ Example structure:

Use **snake_case** names for lists (`yes_no`, `region_list`, `priority_level`).

### How shared lists render on device

By default, a plain `oneOf` / `$ref` field renders as a **native HTML `<select>`** inside outlined styling. This avoids opening the on-screen keyboard on tablets and works reliably inside Formulus WebViews (MUI Menu portals do not).

| Goal | `ui.json` control `options` |
|------|----------------------------|
| Default dropdown (recommended) | Omit `display` — native select is used |
| Searchable long list | `"autocomplete": true` |
| Yes/No as horizontal buttons | `"display": "buttons", "orientation": "horizontal"` |
| Radio list | `"display": "radio"` |
| Localized placeholder | `"placeholder": "Select…"` |

For multi-select array fields, use `"display": "checkboxes"` or `"display": "buttons"`. See [Form design → Control options](./form-design#control-options).

---

### Walkthrough A — Create your first shared list (step by step)
Expand Down
1 change: 1 addition & 0 deletions docs/guides/custom-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Use sub-observations when related answers should live **inside the parent observ
| `allowDelete` | optional | Default `true`. |
| `subObservationInitValues` | optional | Map merged into **initial params** when **adding** a new embedded child. Values support templates `{{parentValue}}`, `{{currentInstanceId}}`, or `{{dot.path}}` 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 skips the injected Finalize page and auto-submits from the last content page. 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 })`. |

Example property on the parent schema:

Expand Down
100 changes: 99 additions & 1 deletion docs/guides/form-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,19 @@ The UI schema defines how form fields are presented to users. It controls layout
- **Progress Tracking**: Shows progress bar indicating current page
- **Auto-wrapping**: If root is not SwipeLayout, Formplayer automatically wraps it

**SwipeLayout `options`:**

| Option | Values | Description |
|--------|--------|-------------|
| `labelLayout` | `"inline"` (default) \| `"stacked"` | Compact two-column layout (title left, input right) vs classic stacked fields |
| `headerFields` | string[] (max 2) | Read-only field keys shown under the progress bar on every page |
| `showInnerTitle` | boolean (default `false`) | Show the form `schema.title` in the inner header (off by default to avoid duplicating the Formulus chrome) |
| `autoFocusFirstInput` | boolean (default `true`) | Focus the first text input when a page opens (keeps the keyboard open across swipes) |
| `nextButtonLabel` | string | Override the Next button label |
| `finalizeButtonLabel` | string | Override the label on the last content page before Finalize |

Per-field override: set `"options": { "labelLayout": "stacked" }` on a `Control` to force a full-width stacked row (useful for photo, signature, GPS, or wide button groups).

**Safe Example:**
```json
{
Expand Down Expand Up @@ -510,7 +523,70 @@ ODE supports various question types through the Formplayer component. Question t

Use **`format: sub-observation`** on an array property for **embedded repeats**: each nested completion stores JSON on the parent observation. Adding or editing opens the linked child form in **sub-observation mode**, so Synkronus still receives **one** parent observation payload.

See [Custom Extensions](./custom-extensions.md#sub-observations-format-sub-observation) for schema keys (`linkedForm`, `parentKey`, `parentValuePath`, `subObservationInitValues`, templates, etc.).
See [Custom Extensions](./custom-extensions.md#sub-observations-format-sub-observation) for schema keys (`linkedForm`, `parentKey`, `parentValuePath`, `subObservationInitValues`, `skipFinalize`, templates, etc.).

## Control options

Form authors can tune presentation and behaviour per field via the `options` object on a `Control` in `ui.json`.

### Choice display

Plain `oneOf` / shared `$ref` single-select fields default to a **native HTML `<select>`** (keyboard-free, reliable in Formulus WebViews). Opt back into the searchable Autocomplete with `"autocomplete": true`.

| Option | Values | Applies to |
|--------|--------|------------|
| `display` | `"radio"` \| `"buttons"` | Single-select |
| `display` | `"checkboxes"` \| `"buttons"` | Multi-select (`type: array` with `uniqueItems`) |
| `orientation` | `"vertical"` (default) \| `"horizontal"` \| `"flow"` | Radio, checkbox, and button modes |
| `buttonGroup` | `"segmented"` (default) \| `"separated"` | Button modes |
| `autocomplete` | `true` | Single-select — use searchable Autocomplete instead of native select |
| `placeholder` | string | Placeholder for native select (e.g. `"Select…"`) |

**Tap-to-clear:** In `radio` and `buttons` single-select modes, tapping the already-selected option clears the answer.

**Example — horizontal Yes/No buttons:**

```json
{
"type": "Control",
"scope": "#/properties/consent",
"options": {
"display": "buttons",
"orientation": "horizontal"
}
}
```

### Sticky fields

Set `"sticky": true` to remember the last submitted scalar value for that field. Values are stored in device `localStorage`, keyed by form type + version + field path, and applied as the **lowest-precedence** default on **new** observations only (not edits, drafts, or sub-observation sessions).

```json
{
"type": "Control",
"scope": "#/properties/interviewer_id",
"options": { "sticky": true }
}
```

### Dynamic schema defaults

For new observations, Formplayer resolves a small set of **default tokens** in `schema.json` property `default` values (only when the field is still empty after params / `defaultData`):

| Token | Resolves to |
|-------|-------------|
| `$today` | Local calendar date `YYYY-MM-DD` (`format: "date"`) |
| `$now` | ISO 8601 date-time (`format: "date-time"`) |

Static `default` literals are unchanged.

### Deferred validation

New observations start with validation errors hidden (`ValidateAndHide`). Errors appear after the user navigates forward or reaches Finalize. Edits and draft resumes show validation immediately. Override via `params.validationMode`: `"ValidateAndShow"`, `"ValidateAndHide"`, or `"NoValidation"`.

### Number bounds

`minimum` / `maximum` are **validation constraints**, not per-keystroke clamps. Users can type out-of-range values; AJV surfaces errors on blur and at finalize. Stepper +/- buttons still respect bounds.

## Working with Media & Special Field Types

Expand Down Expand Up @@ -934,6 +1010,28 @@ Show field when boolean is true:
}
```

#### Multi-select choice check

For **array** multi-select fields, use `contains` with `const` (not `const` on the array itself):

```json
{
"type": "Control",
"scope": "#/properties/other_symptoms",
"rule": {
"effect": "SHOW",
"condition": {
"scope": "#/properties/symptoms",
"schema": {
"contains": { "const": "other" }
}
}
}
}
```

Custom question types (`format` renderers) and the root `SwipeLayout` element also honour `SHOW` / `HIDE` rules.

### Unsafe Patterns

#### ❌ Rule Referencing Missing Field
Expand Down
30 changes: 29 additions & 1 deletion docs/reference/formplayer-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,21 @@ addFormats(ajv); // Standard format validators (date, email, etc.)
- Each element in `elements` becomes a page
- Progress indicator shows current page
- Navigation buttons (Previous/Next) provided automatically
- Root `SwipeLayout` honours `SHOW` / `HIDE` rules (same as nested pages)
- Default `labelLayout` is `"inline"` (compact two-column rows); set `"stacked"` for classic layout
- Optional `headerFields` (up to two schema keys) show read-only context under the progress bar
- `showInnerTitle` defaults to `false` (form title lives in Formulus chrome unless opted in)

**SwipeLayout `options`:**

| Option | Values | Notes |
|--------|--------|-------|
| `labelLayout` | `"inline"` \| `"stacked"` | Default `"inline"` |
| `headerFields` | `string[]` | Max 2 field keys |
| `showInnerTitle` | `boolean` | Default `false` |
| `autoFocusFirstInput` | `boolean` | Default `true` |
| `nextButtonLabel` | `string` | Custom Next label |
| `finalizeButtonLabel` | `string` | Last content page label |

#### VerticalLayout

Expand Down Expand Up @@ -394,9 +409,22 @@ addFormats(ajv); // Standard format validators (date, email, etc.)

**Optional:**
- `label`: String or `false` (to hide label)
- `options`: Object with renderer-specific options
- `options`: Object with renderer-specific options (see below)
- `rule`: Conditional display/enable rule

**Common `options`:**

| Option | Values | Description |
|--------|--------|-------------|
| `labelLayout` | `"inline"` \| `"stacked"` | Per-field layout override (inherits SwipeLayout default) |
| `sticky` | `true` | Remember last submitted scalar value for new observations |
| `display` | `"radio"` \| `"buttons"` \| `"checkboxes"` | Choice layout (single- or multi-select) |
| `orientation` | `"vertical"` \| `"horizontal"` \| `"flow"` | Choice layout direction |
| `buttonGroup` | `"segmented"` \| `"separated"` | Button-group styling |
| `autocomplete` | `true` | Searchable Autocomplete for single-select (default is native `<select>`) |
| `placeholder` | `string` | Native select placeholder |
| `multi` | `true` | Multi-line text input |

**Scope Format:**
- Must start with `#/properties/`
- Examples:
Expand Down
14 changes: 12 additions & 2 deletions docs/reference/formplayer.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,18 @@ Formplayer supports various question types through custom renderers:

### Selection

- **Single Select**: Dropdown or radio buttons
- **Multi Select**: Checkboxes for multiple choices
- **Single Select**: Native `<select>` dropdown by default for `oneOf` / `$ref` lists; optional Autocomplete (`options.autocomplete`), radio, or button groups
- **Multi Select**: Vertical checkboxes by default; optional checkbox or button groups with `options.display`

### Form UX (2026)

- **Inline layout**: SwipeLayout forms default to compact two-column rows (`labelLayout: "inline"`)
- **Sticky fields**: Opt-in per-control value memory (`options.sticky`)
- **Deferred validation**: New forms hide errors until first forward navigation
- **Sub-observation fast path**: `skipFinalize` skips the Finalize page for nested child forms
- **Dynamic defaults**: Schema `default: "$today"` / `"$now"` for new observations

See [Form design guide](../guides/form-design) for `ui.json` examples.

### Boolean

Expand Down
53 changes: 50 additions & 3 deletions docs/reference/formulus.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,62 @@ const observations = await api.getObservationsByQuery({

**Returns:** Promise resolving to an array of observations

#### sync()
#### sync(options?)

Trigger manual synchronization.

```javascript
await api.sync();
const { version } = await api.sync();
// Optional: include attachments (slower)
await api.sync({ includeAttachments: true });
```

**Returns:** Promise that resolves when sync completes
**Returns:** `Promise<{ version: number }>` — the server's data revision after sync completes.

#### getConnectivityStatus()

Probe whether the configured Synkronus server answers `GET /health`. Never rejects for offline devices — returns `{ online: false }`.

```javascript
const status = await api.getConnectivityStatus();
// { online: boolean, serverUrl: string | null, checkedAt: number }
```

Use for "verify when online, fall back when offline" workflows in custom apps.

#### getCurrentDataRevisionCount()

Read the device's last-known Synkronus data revision (`current_version` from the most recent successful sync).

```javascript
const revision = await api.getCurrentDataRevisionCount(); // number, 0 if never synced
```

Reflects **server-stream alignment only** — not unsynced local edits. Poll after `sync()` or on an interval to detect remote changes from other devices.

#### persistObservation(input)

Persist an observation **without opening Formplayer** (headless write). Uses the same path as a Formplayer submit.

```javascript
const result = await api.persistObservation({
formType: 'survey',
finalData: { name: 'Ada', age: 30 },
observationId: null, // omit or null to create; provide id to update
});
// { observationId, formData }
```

#### openFormplayer options

When opening forms programmatically, `openFormplayer` accepts:

| Option | Description |
|--------|-------------|
| `subObservationMode` | Nested child form for embedded sub-observations |
| `skipFinalize` | Skip Finalize page; auto-submit from last content page |

**Form init `params` reserved keys** (not persisted as observation data): `defaultData`, `theme`, `darkMode`, `themeColors`, `context` (read-only session context exposed in Formplayer as `window.formulusSessionContext`), `validationMode`.

## Database Schema

Expand Down
Loading