From 150df74f3f40e1cd41fb58cc6526ac2507bc4770 Mon Sep 17 00:00:00 2001 From: UEAB ODeL Date: Thu, 21 May 2026 17:54:49 +0300 Subject: [PATCH 1/2] refine choice lists documentation: update shared and dynamic lists descriptions, improve naming conventions, and clarify usage examples --- docs/guides/choice-lists.md | 213 +++++++++++++++++++----------------- 1 file changed, 113 insertions(+), 100 deletions(-) diff --git a/docs/guides/choice-lists.md b/docs/guides/choice-lists.md index 334b5e3..d0b9ef0 100644 --- a/docs/guides/choice-lists.md +++ b/docs/guides/choice-lists.md @@ -8,12 +8,8 @@ How to **use**, **add**, **update**, and **remove** dropdown options in ODE cust | Mechanism | When to use | Where you edit | |-----------|-------------|----------------| -| **Shared choice lists** | Fixed study-wide options (yes/no, staff names, village codes) reused across forms | `forms/shared-choice-defs.schema.json` in your bundle + each form `schema.json` | -| **Dynamic choice lists** | Options loaded from **observations already on the device** (person picker, households in a village, etc.) | Form `schema.json` only (`x-dynamicEnum`) | - -:::info Reference implementation -[AnthroCollect](https://github.com/OpenDataEnsemble/AnthroCollect) ships both patterns. In that repo, the same content lives in `forms/CHOICE_LISTS_GUIDE.md` next to the form schemas. -::: +| **Shared choice lists** | Fixed options (yes/no, roles, regions) reused across many forms | `forms/shared-choice-defs.schema.json` in your bundle + each form `schema.json` | +| **Dynamic choice lists** | Options loaded from **observations already on the device** (sites, participants, visits, etc.) | Form `schema.json` only (`x-dynamicEnum`) | **Related guides:** [Observation queries and local indexes](./observation-queries) (performance indexes for dynamic filters), [Form design](./form-design), [Custom extensions](./custom-extensions). @@ -36,27 +32,34 @@ Each list is a `$defs` entry using `oneOf` with `const` (stored value) and `titl "$id": "forms/shared-choice-defs.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "$defs": { - "yesno": { + "yes_no": { "oneOf": [ { "const": "yes", "title": "Yes" }, { "const": "no", "title": "No" } ] + }, + "region_list": { + "oneOf": [ + { "const": "north", "title": "North region" }, + { "const": "south", "title": "South region" }, + { "const": "other", "title": "Other" } + ] } } } ``` -Your app defines its own `$defs` keys. AnthroCollect currently includes lists such as `yesno`, `researcher_list`, `assistant_list`, `villages`, and `kopria_village` — open that file in your bundle for the full option list. +Name each list with **snake_case** keys (`yes_no`, `region_list`, `enumerator_list`, …). Open your bundle’s catalog file to see every list your project defines. ### Reference a list in a form In the form’s `schema.json`, point the field at the shared definition: ```json -"p_hh_res_validation": { +"assigned_region": { "type": "string", - "title": "Household residential validation village", - "$ref": "forms/shared-choice-defs.schema.json#/$defs/villages" + "title": "Assigned region", + "$ref": "forms/shared-choice-defs.schema.json#/$defs/region_list" } ``` @@ -71,9 +74,9 @@ Keep `type`, `title`, and `description` on the property as needed. The `$ref` su **“Other” options:** when the list includes `other`, add a companion free-text field: ```json -"p_hh_res_validation_other": { +"assigned_region_other": { "type": "string", - "title": "Other village (specify)", + "title": "Other region (specify)", "maxLength": 200 } ``` @@ -81,19 +84,20 @@ Keep `type`, `title`, and `description` on the property as needed. The `$ref` su ### Add a new shared list 1. Edit **`forms/shared-choice-defs.schema.json`**. -2. Add a new `$defs` key (use **snake_case**), for example: +2. Add a new `$defs` key, for example: ```json -"my_new_list": { +"priority_level": { "oneOf": [ - { "const": "option_a", "title": "Option A" }, - { "const": "option_b", "title": "Option B" } + { "const": "low", "title": "Low" }, + { "const": "medium", "title": "Medium" }, + { "const": "high", "title": "High" } ] } ``` 3. Reference it from any form with `$ref` as above. -4. Validate the bundle (AnthroCollect example): +4. Validate the bundle (if your project provides a script): ```powershell cd app @@ -112,7 +116,7 @@ Validation resolves shared `$ref` and fails if a form references a missing `$def ### Remove options or lists -1. **One option:** remove its `oneOf` entry; search the repo for the old `const` in `forms/`. +1. **One option:** remove its `oneOf` entry; search your `forms/` tree for the old `const`. 2. **Whole list:** delete the `$defs` key and remove every `$ref` to `#/$defs/`. 3. Re-run form validation. @@ -126,35 +130,35 @@ Validation resolves shared `$ref` and fails if a form references a missing `$def ### What they are -Dynamic lists populate dropdowns from the **local observation database** at runtime (offline). Configure a field with **`x-dynamicEnum`** in `schema.json`. Formplayer uses the **`DynamicEnumControl`** renderer and calls **`getDynamicChoiceList`** (provided by your custom app extensions, e.g. AnthroCollect’s `queryHelpers.js`). +Dynamic lists populate dropdowns from the **local observation database** at runtime (offline). Configure a field with **`x-dynamicEnum`** in `schema.json`. Formplayer uses the **`DynamicEnumControl`** renderer and calls **`getDynamicChoiceList`** from your custom app extensions (typically implemented in `queryHelpers.js` in the app source). -Use dynamic lists when choices depend on data already collected. Use **shared** lists when options are fixed by the protocol. +Use dynamic lists when choices depend on data already collected. Use **shared** lists when options are fixed by the study protocol. :::tip Performance -Filters run through **`getObservationsByQuery`** with a structured filter AST. Declare hot filter fields in **`app.config.json`** → `observationIndexes` so queries use the local index table instead of scanning JSON. See [Observation queries](./observation-queries). +Filters run through **`getObservationsByQuery`** with a structured filter AST. Declare frequently filtered fields in **`app.config.json`** → `observationIndexes` so queries use the local index table instead of scanning JSON. See [Observation queries](./observation-queries). ::: ### Prerequisites - Field type **`string`** (saved value is usually `observationId` or a `data.*` value). -- Custom app includes **`getDynamicChoiceList`** on `window.formulus.functions` (via extensions in the app WebView bundle). +- Custom app exposes **`getDynamicChoiceList`** on `window.formulus.functions`. - Observations exist on the device for the `query` form type. ### Configuration ```json -"my_field": { +"selected_participant": { "type": "string", - "title": "Select person", + "title": "Select participant", "x-dynamicEnum": { "function": "getDynamicChoiceList", - "query": "hh_person", + "query": "participant", "params": { - "sex": "male", - "p_hh_res_validation": "{{data.section_1.test_village}}" + "status": "active", + "site_name": "{{data.location.site_name}}" }, "valueField": "observationId", - "labelField": "data.names", + "labelField": "data.display_name", "distinct": false } } @@ -163,10 +167,10 @@ Filters run through **`getObservationsByQuery`** with a structured filter AST. D | Property | Required | Description | |----------|----------|-------------| | `function` | No (default `getDynamicChoiceList`) | Extension function name | -| `query` | **Yes** | Form type to query (folder name under `forms/`, e.g. `household`, `hh_person`) | +| `query` | **Yes** | Form type to query — the folder name under `forms/` (e.g. `site`, `participant`, `visit`) | | `params` | No | Equality filters on observation `data.*` fields; optional `whereClause` | | `valueField` | No (default `observationId`) | Saved value path (`observationId` or `data.field`) | -| `labelField` | No (default `data.names`) | Display label path | +| `labelField` | No (default varies by app) | Display label path | | `distinct` | No (default `false`) | `true` = unique values of `valueField` only | ### Filter `params` @@ -176,30 +180,26 @@ Keys in `params` are **field names on observation JSON** (without a `data.` pref **Cascading / dependent dropdowns** use template values from the current form: ```json -"hh_village_name": "{{data.section_1_cascading.test_village}}" +"site_name": "{{data.location.site_name}}" ``` - Syntax: `{{data.}}` with dots for nested objects. - If the template resolves empty, that filter is **skipped** (broader result set). - The control reloads when templated parent fields change. -**Indexed param keys** (fast when declared in `app.config.json` — AnthroCollect example): - -| Param key | Typical `query` | Meaning | -|-----------|-----------------|---------| -| `hh_id` | any | Household ID | -| `p_id` | any | Person ID | -| `hh_village_name` | `household` | Household village | -| `village` | `hh_person` | Person `village` | -| `p_hh_res_validation` | `hh_person` | Person residential validation village | -| `sex` | `hh_person` | `male` / `female` | +**Indexed params (recommended for performance):** declare matching keys in `app.config.json` → `observationIndexes`. Example pattern (your keys will match your form design): -**Aliases** (AnthroCollect registry): +| Example param key | Example `query` form | Typical meaning | +|-------------------|----------------------|-----------------| +| `parent_id` | any | Link to a parent record | +| `record_id` | any | Stable record identifier | +| `site_name` | `site` | Site or location name | +| `region` | `participant` | Region on a participant record | +| `status` | `participant` | Status flag (`active`, `inactive`, …) | -- `household` + param `village` → same as `hh_village_name` -- `hh_person` + param `hh_village_name` → filters `p_hh_res_validation` +Any param key can work; undeclared keys fall back to slower `json_extract` queries until you add an index entry. -Other keys still work but may use slower `json_extract` until an index is added. +Some custom apps also map **aliases** (e.g. filtering `participant` records by a field name that differs from the param key). Check your app’s query registry or extension code if filters behave unexpectedly. ### `whereClause` in `params` @@ -207,95 +207,119 @@ For extra conditions, add a SQL-style fragment: ```json "params": { - "sex": "female", - "whereClause": "data.p_age_participant_estimate >= 18" + "status": "active", + "whereClause": "data.age_years >= 18" } ``` - Simple equalities like `data.field = 'value'` are parsed into the filter AST. - **`age_from_dob(...)`** is evaluated in **JavaScript after** the query (not in SQL). -- Numeric comparisons (`>=`, `<=`) on arbitrary fields may fall back to post-query behavior depending on the parser — prefer indexed equality `params` when possible. +- Numeric comparisons (`>=`, `<=`) on arbitrary fields may not use the index table — prefer indexed equality `params` when possible. ### `distinct: true` -Use for categorical values (e.g. all village names from households): +Use when you only need unique values of one field (e.g. every site name already registered): ```json "x-dynamicEnum": { - "query": "household", + "query": "site", "params": {}, - "valueField": "data.hh_village_name", - "labelField": "data.hh_village_name", + "valueField": "data.site_name", + "labelField": "data.site_name", "distinct": true } ``` ### Examples -**Distinct villages from households** +The examples below use **fictional** form types and fields. Replace them with names from your own bundle. + +**Example A — Distinct site names** + +Form type `site` stores `data.site_name`. Show each name once in the dropdown. ```json -"test_village": { +"site_name": { "type": "string", - "title": "Select village", + "title": "Site", "x-dynamicEnum": { - "query": "household", + "query": "site", "params": {}, - "valueField": "data.hh_village_name", - "labelField": "data.hh_village_name", + "valueField": "data.site_name", + "labelField": "data.site_name", "distinct": true } } ``` -**Subvillages filtered by selected village** +**Example B — Districts filtered by selected site (cascading)** + +After the user picks a site, list districts only for that site. ```json -"test_subvillage": { +"district": { "type": "string", - "title": "Select subvillage", + "title": "District", "x-dynamicEnum": { - "query": "household", + "query": "site", "params": { - "hh_village_name": "{{data.test_village}}" + "site_name": "{{data.site_name}}" }, - "valueField": "data.hh_subvillage", - "labelField": "data.hh_subvillage", + "valueField": "data.district", + "labelField": "data.district", "distinct": true } } ``` -**All persons** +**Example C — Pick any participant (no filter)** ```json -"test_person": { +"participant_id": { "type": "string", - "title": "Select person", + "title": "Participant", "x-dynamicEnum": { - "query": "hh_person", + "query": "participant", "params": {}, "valueField": "observationId", - "labelField": "data.names", + "labelField": "data.display_name", "distinct": false } } ``` -**Males in a village** +**Example D — Active participants at one site** ```json -"test_male_in_village": { +"participant_at_site": { "type": "string", - "title": "Select male in village", + "title": "Participant at this site", "x-dynamicEnum": { - "query": "hh_person", + "query": "participant", "params": { - "sex": "male", - "p_hh_res_validation": "{{data.test_village}}" + "status": "active", + "site_name": "{{data.site_name}}" }, "valueField": "observationId", - "labelField": "data.names", + "labelField": "data.display_name", + "distinct": false + } +} +``` + +**Example E — Adults only (numeric filter in whereClause)** + +```json +"adult_participant": { + "type": "string", + "title": "Adult participant (18+)", + "x-dynamicEnum": { + "query": "participant", + "params": { + "whereClause": "data.age_years >= 18" + }, + "valueField": "observationId", + "labelField": "data.display_name", "distinct": false } } @@ -308,7 +332,7 @@ No special control type. Use a normal **Control** with the correct `scope`; the ```json { "type": "Control", - "scope": "#/properties/my_field" + "scope": "#/properties/selected_participant" } ``` @@ -316,8 +340,8 @@ No special control type. Use a normal **Control** with the correct `scope`; the 1. Add `x-dynamicEnum` on the property in `schema.json`. 2. Add a `Control` in `ui.json`. -3. Confirm `query` matches a form type in the bundle. -4. Validate JSON; test on device with real synced observations. +3. Confirm `query` matches a form type folder under `forms/`. +4. Validate JSON; test on device with synced observations. 5. For a new heavily used filter key, add **`observationIndexes`** in `app.config.json` and re-sync the bundle. ### Troubleshooting @@ -326,9 +350,9 @@ No special control type. Use a normal **Control** with the correct `scope`; the |---------|----------------| | Empty dropdown | Observations for `query`? Filters too strict? Template path correct? | | Dropdown does not update | Parent field in `{{data....}}` must be set first | -| Slow loading | Use indexed `params`; avoid unfiltered queries on large tables | +| Slow loading | Add `observationIndexes` for hot param keys; avoid unfiltered queries on large tables | | `Function not found` | Rebuild custom app with extensions | -| Wrong labels | `labelField` path (`data.names` vs `data.hoh_male`, etc.) | +| Wrong labels | `labelField` path matches your observation JSON (`data.display_name`, etc.) | --- @@ -343,7 +367,7 @@ No special control type. Use a normal **Control** with the correct `scope`; the ## Checklist before release -- [ ] Form validation passes (`validate:forms` or equivalent) +- [ ] Form validation passes (`validate:forms` or your project’s equivalent) - [ ] Shared: new `const` values coordinated with exports / analysis - [ ] Dynamic: `query` form type exists; `params` keys match real observation fields - [ ] Dynamic: hot filters covered by `observationIndexes` where needed @@ -351,24 +375,13 @@ No special control type. Use a normal **Control** with the correct `scope`; the --- -## ODK-X mapping - -| ODK-X | ODE | -|-------|-----| -| Choice lists in CSV | Shared `$defs` or inline `oneOf` | -| Linked table / select person | `x-dynamicEnum` + `query` | -| Choice filters | `params` and/or `whereClause` | -| Cascading selects | `params` with `{{data.field}}` templates | - ---- - ## Implementation (developers) -| Layer | Location | -|-------|----------| -| Shared `$ref` resolution | `formulus` → `sharedChoiceSchema.ts`; bundle validation → `shared-choice-schema.cjs` | -| Dynamic renderer | `formulus-formplayer` → `DynamicEnumControl` | -| Query + filters | Custom app → `getDynamicChoiceList` → `buildDynamicChoiceFilter` → `getObservationsByQuery` | +| Layer | Role | +|-------|------| +| Shared `$ref` resolution | Formulus `sharedChoiceSchema.ts`; bundle validation helpers | +| Dynamic renderer | Formplayer `DynamicEnumControl` | +| Query + filters | Custom app `getDynamicChoiceList` → `buildDynamicChoiceFilter` → `getObservationsByQuery` | | Indexes | `app.config.json` → `observationIndexes` | Legacy URL: [Dynamic choice lists](./dynamic-choice-lists) redirects here. From 3456cd957f8a73a776b525fe244f6a029e891532 Mon Sep 17 00:00:00 2001 From: UEAB ODeL Date: Tue, 26 May 2026 10:24:13 +0300 Subject: [PATCH 2/2] enhance choice lists documentation: clarify dropdown types, improve structure, and consolidate dynamic choice lists into the main guide --- docs/guides/choice-lists.md | 548 ++++++++++++++++++---------- docs/guides/dynamic-choice-lists.md | 9 +- 2 files changed, 369 insertions(+), 188 deletions(-) diff --git a/docs/guides/choice-lists.md b/docs/guides/choice-lists.md index d0b9ef0..2bee3b0 100644 --- a/docs/guides/choice-lists.md +++ b/docs/guides/choice-lists.md @@ -4,28 +4,52 @@ sidebar_position: 4 # Choice lists -How to **use**, **add**, **update**, and **remove** dropdown options in ODE custom app forms. There are two mechanisms: +This guide explains how dropdown menus work in ODE custom app forms. You do **not** need to be a programmer to follow the walkthroughs — you only need to edit JSON files in your app bundle and test on a device. -| Mechanism | When to use | Where you edit | -|-----------|-------------|----------------| -| **Shared choice lists** | Fixed options (yes/no, roles, regions) reused across many forms | `forms/shared-choice-defs.schema.json` in your bundle + each form `schema.json` | -| **Dynamic choice lists** | Options loaded from **observations already on the device** (sites, participants, visits, etc.) | Form `schema.json` only (`x-dynamicEnum`) | +--- + +## Two kinds of dropdowns + +In ODE forms, a “choice list” is any field where the user picks one option from a list. + +| Kind | Plain English | When to use it | +|------|---------------|----------------| +| **Shared choice list** | A **fixed menu** you define once (Yes/No, job roles, regions, …) and reuse in many forms | The options are **known in advance** and do not depend on data already collected | +| **Dynamic choice list** | A menu **filled from observations** already saved on the device (sites, participants, visits, …) | The options **come from earlier forms** or other records on the tablet | + +**Simple rule:** If the list could be printed on a paper protocol, use a **shared** list. If the list only makes sense after people have entered data in the field, use a **dynamic** list. + +**Files you will touch:** + +| Kind | Main files | +|------|------------| +| Shared | `forms/shared-choice-defs.schema.json` + each form’s `schema.json` | +| Dynamic | Only the form’s `schema.json` (and `ui.json` for layout) | -**Related guides:** [Observation queries and local indexes](./observation-queries) (performance indexes for dynamic filters), [Form design](./form-design), [Custom extensions](./custom-extensions). +**Related guides:** [Observation queries and local indexes](./observation-queries) (optional performance tuning for dynamic lists), [Form design](./form-design), [Custom extensions](./custom-extensions). --- -## Shared choice lists +## Part 1 — Shared choice lists -### What they are +### What you are building -Reusable **static** dropdown definitions live in one JSON Schema file at the root of your bundle’s `forms/` folder: +Imagine a single spreadsheet tab named **“All our dropdown menus”**. Every form can point at a row on that tab instead of copying the same options again and again. + +In ODE, that “spreadsheet tab” is one file: **`forms/shared-choice-defs.schema.json`** -Each list is a `$defs` entry using `oneOf` with `const` (stored value) and `title` (label in the UI). Formulus and Formplayer **resolve** external `$ref` pointers into that file when a form loads. +Each menu is a named block inside `$defs`. Forms connect to it with a **`$ref`** link. + +### How a shared list is stored + +Each option has: -### Catalog file shape +- **`const`** — the value saved in the database (short code, e.g. `yes`) +- **`title`** — the label the user sees (e.g. `Yes`) + +Example structure: ```json { @@ -37,54 +61,97 @@ Each list is a `$defs` entry using `oneOf` with `const` (stored value) and `titl { "const": "yes", "title": "Yes" }, { "const": "no", "title": "No" } ] - }, - "region_list": { - "oneOf": [ - { "const": "north", "title": "North region" }, - { "const": "south", "title": "South region" }, - { "const": "other", "title": "Other" } - ] } } } ``` -Name each list with **snake_case** keys (`yes_no`, `region_list`, `enumerator_list`, …). Open your bundle’s catalog file to see every list your project defines. +Use **snake_case** names for lists (`yes_no`, `region_list`, `priority_level`). + +--- + +### Walkthrough A — Create your first shared list (step by step) + +**Goal:** Add a Yes/No question that every form can reuse. + +**Step 1 — Open the catalog file** -### Reference a list in a form +Open `forms/shared-choice-defs.schema.json` in your bundle. -In the form’s `schema.json`, point the field at the shared definition: +**Step 2 — Add a new list under `$defs`** + +If `yes_no` is not there yet, add: ```json -"assigned_region": { +"yes_no": { + "oneOf": [ + { "const": "yes", "title": "Yes" }, + { "const": "no", "title": "No" } + ] +} +``` + +**Step 3 — Save the file** + +**Step 4 — Validate (if your project has a script)** + +```powershell +cd app +npm run validate:forms +``` + +Fix any errors before continuing. + +You have now created a shared list. Next, attach it to a form field. + +--- + +### Walkthrough B — Use a shared list on one form field (step by step) + +**Goal:** On form `visit`, field `completed_survey`, show the Yes/No menu. + +**Step 1 — Open the form schema** + +Open `forms/visit/schema.json` (replace `visit` with your form folder name). + +**Step 2 — Add or edit the property** + +```json +"completed_survey": { "type": "string", - "title": "Assigned region", - "$ref": "forms/shared-choice-defs.schema.json#/$defs/region_list" + "title": "Was the survey completed?", + "$ref": "forms/shared-choice-defs.schema.json#/$defs/yes_no" } ``` -**URI (must match exactly):** +**Important:** The `$ref` line must match **exactly**: ``` forms/shared-choice-defs.schema.json#/$defs/ ``` -Keep `type`, `title`, and `description` on the property as needed. The `$ref` supplies the allowed values. - -**“Other” options:** when the list includes `other`, add a companion free-text field: +**Step 3 — Add the control in `ui.json`** ```json -"assigned_region_other": { - "type": "string", - "title": "Other region (specify)", - "maxLength": 200 +{ + "type": "Control", + "scope": "#/properties/completed_survey" } ``` -### Add a new shared list +**Step 4 — Validate and test** + +Run form validation, sync the bundle to a device, open the form, and confirm both options appear. + +--- + +### Walkthrough C — Add a new list with several options (step by step) + +**Goal:** A “Priority” dropdown with Low / Medium / High, used on multiple forms. + +**Step 1 — Add the list to the catalog** -1. Edit **`forms/shared-choice-defs.schema.json`**. -2. Add a new `$defs` key, for example: +In `shared-choice-defs.schema.json`: ```json "priority_level": { @@ -96,55 +163,117 @@ Keep `type`, `title`, and `description` on the property as needed. The `$ref` su } ``` -3. Reference it from any form with `$ref` as above. -4. Validate the bundle (if your project provides a script): +**Step 2 — Reference it from a form** -```powershell -cd app -npm run validate:forms +In any form `schema.json`: + +```json +"task_priority": { + "type": "string", + "title": "Priority", + "$ref": "forms/shared-choice-defs.schema.json#/$defs/priority_level" +} +``` + +**Step 3 — Validate** + +Search your `forms/` folder to ensure no form still points at a deleted list name. + +--- + +### Walkthrough D — “Other, please specify” pattern (step by step) + +**Goal:** User picks “Other” and then types a short explanation. + +**Step 1 — Include `other` in the shared list** + +```json +"region_list": { + "oneOf": [ + { "const": "north", "title": "North region" }, + { "const": "south", "title": "South region" }, + { "const": "other", "title": "Other" } + ] +} ``` -Validation resolves shared `$ref` and fails if a form references a missing `$defs` name. +**Step 2 — Add the main dropdown on the form** -### Update options +```json +"assigned_region": { + "type": "string", + "title": "Assigned region", + "$ref": "forms/shared-choice-defs.schema.json#/$defs/region_list" +} +``` -| Change | Safe? | Notes | -|--------|-------|-------| -| Edit `title` only | Yes | Display label changes; stored `const` unchanged | -| Add a `oneOf` entry | Yes | New option appears in all forms using the list | -| Change `const` | Risky | Existing observations keep the old string; plan migration | +**Step 3 — Add a free-text field for the explanation** -### Remove options or lists +```json +"assigned_region_other": { + "type": "string", + "title": "Other region (please specify)", + "maxLength": 200 +} +``` + +**Step 4 — In `ui.json`** + +Show both controls. Use your form’s usual rules (or JSON Forms rules) to show `assigned_region_other` only when `assigned_region` is `other`. + +--- -1. **One option:** remove its `oneOf` entry; search your `forms/` tree for the old `const`. -2. **Whole list:** delete the `$defs` key and remove every `$ref` to `#/$defs/`. -3. Re-run form validation. +### Changing or removing shared options + +| What you want | Steps | Safe for old data? | +|---------------|-------|--------------------| +| Change label only | Edit `title`, keep `const` the same | Yes | +| Add a new option | Add one `oneOf` entry | Yes | +| Rename stored value | Change `const` | **Risky** — old records keep the old code | +| Remove an option | Delete its `oneOf` entry; search forms for that `const` | Only if nothing stored that value | +| Delete whole list | Remove `$defs` entry and every `$ref` to it | Only after forms are updated | + +--- ### Deploy shared list changes -`shared-choice-defs.schema.json` is packaged with `forms/` in the app bundle. Upload a new bundle version and sync devices so Formulus loads the updated definitions. +`shared-choice-defs.schema.json` ships inside your app bundle with the `forms/` folder. + +1. Upload a **new bundle version** (Synkronus CLI or your usual process). +2. **Sync** devices so Formulus loads the updated definitions. --- -## Dynamic choice lists +## Part 2 — Dynamic choice lists + +### What you are building + +A dynamic dropdown does **not** store its options in `shared-choice-defs.schema.json`. Instead, when the user opens the form, the app: -### What they are +1. Looks at observations already on the device (e.g. all `participant` records). +2. Applies any filters you configured (e.g. only `status = active`). +3. Builds the dropdown labels from those records. -Dynamic lists populate dropdowns from the **local observation database** at runtime (offline). Configure a field with **`x-dynamicEnum`** in `schema.json`. Formplayer uses the **`DynamicEnumControl`** renderer and calls **`getDynamicChoiceList`** from your custom app extensions (typically implemented in `queryHelpers.js` in the app source). +You configure this on **one field** in `schema.json` using **`x-dynamicEnum`**. -Use dynamic lists when choices depend on data already collected. Use **shared** lists when options are fixed by the study protocol. +### Before you start — checklist -:::tip Performance -Filters run through **`getObservationsByQuery`** with a structured filter AST. Declare frequently filtered fields in **`app.config.json`** → `observationIndexes` so queries use the local index table instead of scanning JSON. See [Observation queries](./observation-queries). +| Requirement | Why | +|-------------|-----| +| Field type is **`string`** | The saved answer is text (often an observation id or a field value) | +| Form type **`query`** exists | `query` is the folder name under `forms/` (e.g. `forms/participant/`) | +| Data exists on the device | Empty table → empty dropdown | +| Custom app provides **`getDynamicChoiceList`** | Usually in your app extensions; ships with the bundle | + +:::tip +Test dynamic fields **after** syncing observations that match your `query` form type. An empty list is often “no data yet,” not a broken config. ::: -### Prerequisites +--- -- Field type **`string`** (saved value is usually `observationId` or a `data.*` value). -- Custom app exposes **`getDynamicChoiceList`** on `window.formulus.functions`. -- Observations exist on the device for the `query` form type. +### The five settings that matter most -### Configuration +Full example first; then each part in plain language. ```json "selected_participant": { @@ -154,8 +283,7 @@ Filters run through **`getObservationsByQuery`** with a structured filter AST. D "function": "getDynamicChoiceList", "query": "participant", "params": { - "status": "active", - "site_name": "{{data.location.site_name}}" + "status": "active" }, "valueField": "observationId", "labelField": "data.display_name", @@ -164,79 +292,74 @@ Filters run through **`getObservationsByQuery`** with a structured filter AST. D } ``` -| Property | Required | Description | -|----------|----------|-------------| -| `function` | No (default `getDynamicChoiceList`) | Extension function name | -| `query` | **Yes** | Form type to query — the folder name under `forms/` (e.g. `site`, `participant`, `visit`) | -| `params` | No | Equality filters on observation `data.*` fields; optional `whereClause` | -| `valueField` | No (default `observationId`) | Saved value path (`observationId` or `data.field`) | -| `labelField` | No (default varies by app) | Display label path | -| `distinct` | No (default `false`) | `true` = unique values of `valueField` only | - -### Filter `params` +| Setting | Required? | What it does | +|---------|-----------|--------------| +| **`query`** | **Yes** | Which form type to read (`participant` → folder `forms/participant/`) | +| **`params`** | No | Only show rows where these **data fields** match (equality filters) | +| **`valueField`** | No (default: observation id) | What gets **saved** when the user picks an option | +| **`labelField`** | No | What the user **sees** in the dropdown | +| **`distinct`** | No (default: `false`) | `true` = one row per unique **value** (good for “list all site names”) | +| **`function`** | No | Name of the extension function (almost always `getDynamicChoiceList`) | -Keys in `params` are **field names on observation JSON** (without a `data.` prefix in the schema; the runtime adds `data.` when building the query). +**Field names in `params`:** use the name as it appears **inside** observation data (e.g. `site_name`), not `data.site_name`. The platform adds the `data.` prefix when querying. -**Cascading / dependent dropdowns** use template values from the current form: - -```json -"site_name": "{{data.location.site_name}}" -``` +--- -- Syntax: `{{data.}}` with dots for nested objects. -- If the template resolves empty, that filter is **skipped** (broader result set). -- The control reloads when templated parent fields change. +### Walkthrough 1 — Pick one record from a list (participant picker) -**Indexed params (recommended for performance):** declare matching keys in `app.config.json` → `observationIndexes`. Example pattern (your keys will match your form design): +**Goal:** User chooses any participant; save the participant’s observation id. -| Example param key | Example `query` form | Typical meaning | -|-------------------|----------------------|-----------------| -| `parent_id` | any | Link to a parent record | -| `record_id` | any | Stable record identifier | -| `site_name` | `site` | Site or location name | -| `region` | `participant` | Region on a participant record | -| `status` | `participant` | Status flag (`active`, `inactive`, …) | +**Assumptions:** -Any param key can work; undeclared keys fall back to slower `json_extract` queries until you add an index entry. +- Form type folder: `forms/participant/` +- Each participant observation has `data.display_name` (e.g. `"Ada Lovelace"`) -Some custom apps also map **aliases** (e.g. filtering `participant` records by a field name that differs from the param key). Check your app’s query registry or extension code if filters behave unexpectedly. +**Step 1 — Add the field in `schema.json`** -### `whereClause` in `params` +```json +"participant_id": { + "type": "string", + "title": "Participant", + "x-dynamicEnum": { + "query": "participant", + "params": {}, + "valueField": "observationId", + "labelField": "data.display_name", + "distinct": false + } +} +``` -For extra conditions, add a SQL-style fragment: +**Step 2 — Add `Control` in `ui.json`** ```json -"params": { - "status": "active", - "whereClause": "data.age_years >= 18" +{ + "type": "Control", + "scope": "#/properties/participant_id" } ``` -- Simple equalities like `data.field = 'value'` are parsed into the filter AST. -- **`age_from_dob(...)`** is evaluated in **JavaScript after** the query (not in SQL). -- Numeric comparisons (`>=`, `<=`) on arbitrary fields may not use the index table — prefer indexed equality `params` when possible. +**Step 3 — Test on device** -### `distinct: true` +1. Sync so at least one `participant` observation exists. +2. Open the form with this field. +3. Open the dropdown — you should see display names. +4. Save the form and confirm `participant_id` stores an observation id. -Use when you only need unique values of one field (e.g. every site name already registered): +**What gets saved:** `observationId` of the chosen row (because `valueField` is `observationId`). -```json -"x-dynamicEnum": { - "query": "site", - "params": {}, - "valueField": "data.site_name", - "labelField": "data.site_name", - "distinct": true -} -``` +--- -### Examples +### Walkthrough 2 — Show each value only once (distinct site names) -The examples below use **fictional** form types and fields. Replace them with names from your own bundle. +**Goal:** Dropdown lists every **site name** already registered, with no duplicates. -**Example A — Distinct site names** +**Assumptions:** -Form type `site` stores `data.site_name`. Show each name once in the dropdown. +- Form type: `site` +- Field on each site record: `data.site_name` + +**Step 1 — Add the field** ```json "site_name": { @@ -252,9 +375,33 @@ Form type `site` stores `data.site_name`. Show each name once in the dropdown. } ``` -**Example B — Districts filtered by selected site (cascading)** +**Step 2 — Test** + +Create two `site` observations with the same `site_name` — the dropdown should still show that name **once**. + +**When to use `distinct: true`:** You care about the **value** (name, code, region), not which observation row it came from. + +--- + +### Walkthrough 3 — Cascading dropdown (district depends on site) + +**Goal:** User picks a **site** first; the **district** dropdown only shows districts for that site. + +**Assumptions:** + +- `site` observations have `data.site_name` and `data.district` +- On the current form, the user already filled `site_name` (same form, earlier question) -After the user picks a site, list districts only for that site. +**Step 1 — Site field (could be shared list or dynamic — here we assume plain string)** + +```json +"site_name": { + "type": "string", + "title": "Site name" +} +``` + +**Step 2 — District field filtered by site** ```json "district": { @@ -272,23 +419,28 @@ After the user picks a site, list districts only for that site. } ``` -**Example C — Pick any participant (no filter)** +**Step 3 — Order fields in `ui.json`** -```json -"participant_id": { - "type": "string", - "title": "Participant", - "x-dynamicEnum": { - "query": "participant", - "params": {}, - "valueField": "observationId", - "labelField": "data.display_name", - "distinct": false - } -} -``` +Put **Site name** **above** **District** so the parent value exists before the child loads. + +**How `{{data.site_name}}` works:** + +- Text in double curly braces copies a value from the **current form**. +- `data.` means “inside the form answers object.” +- If the parent is empty, that filter is **skipped** (wider list — often confusing; order fields carefully). -**Example D — Active participants at one site** +**Step 4 — Test** + +1. Pick site A → district list should only show districts seen on site A records. +2. Change site → district list should reload. + +--- + +### Walkthrough 4 — Filtered list (active participants at one site) + +**Goal:** Only participants who are **active** and belong to the **selected site**. + +**Step 1 — Configure `params`** ```json "participant_at_site": { @@ -307,81 +459,111 @@ After the user picks a site, list districts only for that site. } ``` -**Example E — Adults only (numeric filter in whereClause)** +**Step 2 — Match real field names** + +Open a saved `participant` observation on the device (or in export) and confirm fields are really named `status` and `site_name`. If your project uses `site_id` instead, use that key in `params`. + +**Step 3 — Test with edge cases** + +| Situation | Expected behavior | +|-----------|-------------------| +| No participants match | Empty dropdown | +| Parent `site_name` empty | `site_name` filter skipped; may show all active participants | +| Wrong param name | Empty or wrong results — fix spelling to match JSON | + +--- + +### Walkthrough 5 — Extra conditions with `whereClause` (optional) + +**Goal:** Only adults (age 18+) in the list. + +Add inside `params`: ```json -"adult_participant": { - "type": "string", - "title": "Adult participant (18+)", - "x-dynamicEnum": { - "query": "participant", - "params": { - "whereClause": "data.age_years >= 18" - }, - "valueField": "observationId", - "labelField": "data.display_name", - "distinct": false - } +"params": { + "whereClause": "data.age_years >= 18" } ``` -### UI schema (`ui.json`) +**Good to know:** + +- Simple equalities like `data.status = 'active'` are turned into normal filters when possible. +- Some expressions (e.g. age calculated from date of birth) run **after** the database query in JavaScript — they can be slower on large datasets. +- Prefer equality filters in `params` when you can (see performance below). + +--- + +### Performance — making large lists faster (optional) + +When many observations exist, filtering by `params` is much faster if those fields are listed in **`app.config.json`** under **`observationIndexes`**. -No special control type. Use a normal **Control** with the correct `scope`; the renderer is chosen automatically when `x-dynamicEnum` is present: +Example (your keys and form types will differ): ```json -{ - "type": "Control", - "scope": "#/properties/selected_participant" -} +"observationIndexes": [ + { "key": "site_name", "path": "$.site_name", "formTypes": ["site", "participant"] }, + { "key": "status", "path": "$.status", "formTypes": ["participant"] } +] ``` -### Add, update, or remove a dynamic field +Param keys in `x-dynamicEnum` should **match** `key` values when possible. Details: [Observation queries and local indexes](./observation-queries). + +You do **not** need indexes for small pilots; add them when dropdowns feel slow. + +--- + +### Add, change, or remove a dynamic field — checklist 1. Add `x-dynamicEnum` on the property in `schema.json`. -2. Add a `Control` in `ui.json`. -3. Confirm `query` matches a form type folder under `forms/`. -4. Validate JSON; test on device with synced observations. -5. For a new heavily used filter key, add **`observationIndexes`** in `app.config.json` and re-sync the bundle. +2. Add a `Control` in `ui.json` with the matching `scope`. +3. Confirm `query` equals a folder name under `forms/`. +4. Validate JSON. +5. Sync test data and try on device. +6. (Optional) Add `observationIndexes` for heavily used filter keys. + +--- -### Troubleshooting +### Dynamic lists — troubleshooting -| Problem | What to check | -|---------|----------------| -| Empty dropdown | Observations for `query`? Filters too strict? Template path correct? | -| Dropdown does not update | Parent field in `{{data....}}` must be set first | -| Slow loading | Add `observationIndexes` for hot param keys; avoid unfiltered queries on large tables | -| `Function not found` | Rebuild custom app with extensions | -| Wrong labels | `labelField` path matches your observation JSON (`data.display_name`, etc.) | +| What you see | What to check | +|--------------|----------------| +| Empty dropdown | Any observations for `query`? Filters too strict? Typo in `params` keys? | +| List never updates when parent changes | Parent field must be filled first; check `{{data....}}` path | +| Wrong text in dropdown | `labelField` must match real JSON (`data.display_name`, etc.) | +| Saved value looks wrong | `valueField` — `observationId` vs `data.some_code` | +| `Function not found` | Rebuild/sync bundle with custom app extensions | +| Slow on large projects | Add `observationIndexes` for hot `params` keys | --- -## Quick decision guide +## Which type should I use? -| Need | Use | -|------|-----| -| Fixed options defined by the study | Shared list + `$ref` | -| Options from data on the tablet | `x-dynamicEnum` + `getDynamicChoiceList` | +| Your situation | Use | +|----------------|-----| +| Options are fixed in the study protocol | **Shared** list + `$ref` | +| Options come from data already on the tablet | **Dynamic** list + `x-dynamicEnum` | +| Same labels in 10 forms | **Shared** list (one catalog file) | +| “Pick a person who was registered earlier” | **Dynamic** list | --- -## Checklist before release +## Before you release -- [ ] Form validation passes (`validate:forms` or your project’s equivalent) -- [ ] Shared: new `const` values coordinated with exports / analysis -- [ ] Dynamic: `query` form type exists; `params` keys match real observation fields -- [ ] Dynamic: hot filters covered by `observationIndexes` where needed -- [ ] Tested on device with representative observations (dynamic fields) +- [ ] Form validation passes (`validate:forms` or your project equivalent) +- [ ] **Shared:** new stored values (`const`) agreed with analysis / exports +- [ ] **Dynamic:** `query` form exists; `params` names match real observation fields +- [ ] **Dynamic:** tested on device with realistic synced data +- [ ] **Dynamic (optional):** indexes added for large deployments --- -## Implementation (developers) +## For developers (short reference) | Layer | Role | |-------|------| -| Shared `$ref` resolution | Formulus `sharedChoiceSchema.ts`; bundle validation helpers | -| Dynamic renderer | Formplayer `DynamicEnumControl` | -| Query + filters | Custom app `getDynamicChoiceList` → `buildDynamicChoiceFilter` → `getObservationsByQuery` | -| Indexes | `app.config.json` → `observationIndexes` | +| Shared `$ref` | Resolved when forms load (Formulus / validation) | +| `x-dynamicEnum` | Formplayer `DynamicEnumControl` | +| Data fetch | `getDynamicChoiceList` → structured filter → `getObservationsByQuery` | +| Fast filters | `observationIndexes` in `app.config.json` | -Legacy URL: [Dynamic choice lists](./dynamic-choice-lists) redirects here. +Legacy URL: [Dynamic choice lists](./dynamic-choice-lists) redirects to this page. diff --git a/docs/guides/dynamic-choice-lists.md b/docs/guides/dynamic-choice-lists.md index 9df0382..6409d95 100644 --- a/docs/guides/dynamic-choice-lists.md +++ b/docs/guides/dynamic-choice-lists.md @@ -9,11 +9,10 @@ This page has been **consolidated** into the full form-author guide: **[Choice lists →](./choice-lists)** (shared + dynamic choice lists) -That guide reflects the current platform behavior, including: +That guide includes step-by-step walkthroughs for both list types: -- **`forms/shared-choice-defs.schema.json`** for static shared lists (`$ref`) -- **`x-dynamicEnum`** with **`getDynamicChoiceList`** and **`getObservationsByQuery`** -- **Template filters** (`{{data.field}}`) for cascading dropdowns -- **`observationIndexes`** in `app.config.json` for fast filters +- **Shared lists** — catalog file, `$ref`, Yes/No and “Other” patterns +- **Dynamic lists** — participant picker, distinct values, cascading dropdowns, filters +- **Optional performance** — `observationIndexes` in `app.config.json` If you bookmarked this URL, use [Choice lists](./choice-lists) going forward.