diff --git a/docs/guides/choice-lists.md b/docs/guides/choice-lists.md new file mode 100644 index 0000000..334b5e3 --- /dev/null +++ b/docs/guides/choice-lists.md @@ -0,0 +1,374 @@ +--- +sidebar_position: 4 +--- + +# Choice lists + +How to **use**, **add**, **update**, and **remove** dropdown options in ODE custom app forms. There are two mechanisms: + +| 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. +::: + +**Related guides:** [Observation queries and local indexes](./observation-queries) (performance indexes for dynamic filters), [Form design](./form-design), [Custom extensions](./custom-extensions). + +--- + +## Shared choice lists + +### What they are + +Reusable **static** dropdown definitions live in one JSON Schema file at the root of your bundle’s `forms/` folder: + +**`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. + +### Catalog file shape + +```json +{ + "$id": "forms/shared-choice-defs.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "$defs": { + "yesno": { + "oneOf": [ + { "const": "yes", "title": "Yes" }, + { "const": "no", "title": "No" } + ] + } + } +} +``` + +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. + +### Reference a list in a form + +In the form’s `schema.json`, point the field at the shared definition: + +```json +"p_hh_res_validation": { + "type": "string", + "title": "Household residential validation village", + "$ref": "forms/shared-choice-defs.schema.json#/$defs/villages" +} +``` + +**URI (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: + +```json +"p_hh_res_validation_other": { + "type": "string", + "title": "Other village (specify)", + "maxLength": 200 +} +``` + +### Add a new shared list + +1. Edit **`forms/shared-choice-defs.schema.json`**. +2. Add a new `$defs` key (use **snake_case**), for example: + +```json +"my_new_list": { + "oneOf": [ + { "const": "option_a", "title": "Option A" }, + { "const": "option_b", "title": "Option B" } + ] +} +``` + +3. Reference it from any form with `$ref` as above. +4. Validate the bundle (AnthroCollect example): + +```powershell +cd app +npm run validate:forms +``` + +Validation resolves shared `$ref` and fails if a form references a missing `$defs` name. + +### Update options + +| 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 | + +### Remove options or lists + +1. **One option:** remove its `oneOf` entry; search the repo for the old `const` in `forms/`. +2. **Whole list:** delete the `$defs` key and remove every `$ref` to `#/$defs/`. +3. Re-run form validation. + +### 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. + +--- + +## Dynamic choice lists + +### 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`). + +Use dynamic lists when choices depend on data already collected. Use **shared** lists when options are fixed by the 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). +::: + +### 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). +- Observations exist on the device for the `query` form type. + +### Configuration + +```json +"my_field": { + "type": "string", + "title": "Select person", + "x-dynamicEnum": { + "function": "getDynamicChoiceList", + "query": "hh_person", + "params": { + "sex": "male", + "p_hh_res_validation": "{{data.section_1.test_village}}" + }, + "valueField": "observationId", + "labelField": "data.names", + "distinct": false + } +} +``` + +| 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`) | +| `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 | +| `distinct` | No (default `false`) | `true` = unique values of `valueField` only | + +### Filter `params` + +Keys in `params` are **field names on observation JSON** (without a `data.` prefix in the schema; the runtime adds `data.` when building the query). + +**Cascading / dependent dropdowns** use template values from the current form: + +```json +"hh_village_name": "{{data.section_1_cascading.test_village}}" +``` + +- 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` | + +**Aliases** (AnthroCollect registry): + +- `household` + param `village` → same as `hh_village_name` +- `hh_person` + param `hh_village_name` → filters `p_hh_res_validation` + +Other keys still work but may use slower `json_extract` until an index is added. + +### `whereClause` in `params` + +For extra conditions, add a SQL-style fragment: + +```json +"params": { + "sex": "female", + "whereClause": "data.p_age_participant_estimate >= 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. + +### `distinct: true` + +Use for categorical values (e.g. all village names from households): + +```json +"x-dynamicEnum": { + "query": "household", + "params": {}, + "valueField": "data.hh_village_name", + "labelField": "data.hh_village_name", + "distinct": true +} +``` + +### Examples + +**Distinct villages from households** + +```json +"test_village": { + "type": "string", + "title": "Select village", + "x-dynamicEnum": { + "query": "household", + "params": {}, + "valueField": "data.hh_village_name", + "labelField": "data.hh_village_name", + "distinct": true + } +} +``` + +**Subvillages filtered by selected village** + +```json +"test_subvillage": { + "type": "string", + "title": "Select subvillage", + "x-dynamicEnum": { + "query": "household", + "params": { + "hh_village_name": "{{data.test_village}}" + }, + "valueField": "data.hh_subvillage", + "labelField": "data.hh_subvillage", + "distinct": true + } +} +``` + +**All persons** + +```json +"test_person": { + "type": "string", + "title": "Select person", + "x-dynamicEnum": { + "query": "hh_person", + "params": {}, + "valueField": "observationId", + "labelField": "data.names", + "distinct": false + } +} +``` + +**Males in a village** + +```json +"test_male_in_village": { + "type": "string", + "title": "Select male in village", + "x-dynamicEnum": { + "query": "hh_person", + "params": { + "sex": "male", + "p_hh_res_validation": "{{data.test_village}}" + }, + "valueField": "observationId", + "labelField": "data.names", + "distinct": false + } +} +``` + +### UI schema (`ui.json`) + +No special control type. Use a normal **Control** with the correct `scope`; the renderer is chosen automatically when `x-dynamicEnum` is present: + +```json +{ + "type": "Control", + "scope": "#/properties/my_field" +} +``` + +### Add, update, or remove a dynamic field + +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. +5. For a new heavily used filter key, add **`observationIndexes`** in `app.config.json` and re-sync the bundle. + +### 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 | Use indexed `params`; 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.) | + +--- + +## Quick decision guide + +| Need | Use | +|------|-----| +| Fixed options defined by the study | Shared list + `$ref` | +| Options from data on the tablet | `x-dynamicEnum` + `getDynamicChoiceList` | + +--- + +## Checklist before release + +- [ ] Form validation passes (`validate:forms` or 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) + +--- + +## 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` | +| Indexes | `app.config.json` → `observationIndexes` | + +Legacy URL: [Dynamic choice lists](./dynamic-choice-lists) redirects here. diff --git a/docs/guides/dynamic-choice-lists.md b/docs/guides/dynamic-choice-lists.md index ab926a2..9df0382 100644 --- a/docs/guides/dynamic-choice-lists.md +++ b/docs/guides/dynamic-choice-lists.md @@ -1,559 +1,19 @@ --- -sidebar_position: 4 +sidebar_position: 5 +slug: /guides/dynamic-choice-lists --- -# Dynamic Choice Lists +# Dynamic choice lists -Transform static dropdowns into dynamic, data-driven selections that automatically populate from existing observations. +This page has been **consolidated** into the full form-author guide: -:::info Production Ready -Dynamic Choice Lists are production-ready as of v1.0 and provide a complete alternative to the ODK-X "select person" and "linked tables" patterns. -::: +**[Choice lists →](./choice-lists)** (shared + dynamic choice lists) -## Overview +That guide reflects the current platform behavior, including: -Dynamic Choice Lists enable you to populate dropdown menus at runtime from observations stored in the local database, rather than hard-coding choices in the form schema. +- **`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 -**Benefits:** -- ✅ **Data-driven dropdowns** - Automatically updated from real observations -- ✅ **Filtered queries** - Show only relevant choices based on parameters -- ✅ **No schema redeployment** - Add new choices by creating observations -- ✅ **ODK-X compatible** - Replaces linked tables and select person patterns -- ✅ **Performance optimized** - Efficient filtering and caching -- ✅ **Complex logic** - Support for age calculations and advanced filters - -## Quick Start - -### Basic Example: Village Selection - -Static approach (❌ outdated when new villages added): -```json -{ - "village": { - "type": "string", - "enum": ["kopria", "lorenkacho", "chare"] - } -} -``` - -Dynamic approach (✅ automatically updated): -```json -{ - "village": { - "type": "string", - "title": "Select Village", - "x-dynamicEnum": { - "function": "getDynamicChoiceList", - "query": "household", - "params": {}, - "valueField": "data.hh_village_name", - "labelField": "data.hh_village_name", - "distinct": true - } - } -} -``` - -**How it works:** -1. When the form loads, `getDynamicChoiceList` queries observations of type `household` -2. Extracts unique values from `data.hh_village_name` field -3. Displays them as dropdown options -4. Uses the same field for both value and label - -### Understanding the Configuration - -```json -{ - "x-dynamicEnum": { - "function": "getDynamicChoiceList", // Always this value - "query": "formType", // Form type to query (e.g., "household") - "params": {}, // Filter parameters (see below) - "valueField": "data.fieldName", // Path to value in observations - "labelField": "data.fieldName", // Path to display label (optional) - "distinct": true // true = unique values only - } -} -``` - -## Field Path Syntax - -### Form Data Fields - -Reference observation data using dot notation with `data.` prefix: - -```json -"valueField": "data.hh_village_name" // Village name from village observations -"valueField": "data.names" // Person name from person observations -"valueField": "data.sex" // Sex/gender field -``` - -### Metadata Fields - -Access observation metadata without the data prefix: - -```json -"valueField": "observationId" // Unique observation ID -"labelField": "formType" // Form type -"valueField": "createdAt" // Creation timestamp -"labelField": "isDraft" // Draft status -``` - -## Configuration Reference - -### Common Parameters - -| Parameter | Type | Required | Description | Example | -|-----------|------|----------|-------------|---------| -| `function` | string | ✅ Yes | Must be exactly `"getDynamicChoiceList"` | `"getDynamicChoiceList"` | -| `query` | string | ✅ Yes | Form type to query observations from | `"household"`, `"hh_person"` | -| `valueField` | string | ✅ Yes | Path to value field (use `data.` prefix for form fields) | `"data.hh_village_name"`, `"observationId"` | -| `labelField` | string | No | Path to label field (defaults to `valueField`) | `"data.names"` | -| `params` | object | No | Filter parameters (see below) | `{"sex": "male"}` | -| `distinct` | boolean | No | Return only unique values | `true`, `false` | - -### Filtering with Parameters - -Simple equality filters via `params`: - -```json -{ - "male_person": { - "type": "string", - "x-dynamicEnum": { - "function": "getDynamicChoiceList", - "query": "hh_person", - "params": { - "sex": "male" - }, - "valueField": "observationId", - "labelField": "data.names" - } - } -} -``` - -**Automatic conversion:** -``` -params: {"sex": "male"} -↓ -Becomes: WHERE data.sex = 'male' -``` - -### WHERE Clause for Complex Logic - -Use `whereClause` in params for complex filtering: - -```json -{ - "adult_participant": { - "type": "string", - "x-dynamicEnum": { - "function": "getDynamicChoiceList", - "query": "hh_person", - "params": { - "whereClause": "age_from_dob(data.dob) >= 18" - }, - "valueField": "observationId", - "labelField": "data.names" - } - } -} -``` - -### WHERE Clause Operators - -| Operator | Description | Example | -|----------|-------------|---------| -| `=` | Equals | `data.sex = 'male'` | -| `!=` or `<>` | Not equals | `data.sex != 'female'` | -| `<` | Less than | `age_from_dob(data.dob) < 18` | -| `>` | Greater than | `age_from_dob(data.dob) > 65` | -| `<=` | Less than or equal | `age_from_dob(data.dob) <= 30` | -| `>=` | Greater than or equal | `data.p_age >= 18` | -| `AND` | Logical AND | `data.sex = 'female' AND age_from_dob(data.dob) >= 18` | -| `OR` | Logical OR | `data.village = 'A' OR data.village = 'B'` | -| `NOT` | Logical NOT | `NOT (data.archived = true)` | -| `()` | Grouping | `(age_from_dob(data.dob) >= 18 AND age_from_dob(data.dob) <= 30) OR age_from_dob(data.dob) >= 50` | - -**Special Functions:** -- `age_from_dob(data.dob)` - Calculate age from date of birth field (calculated in JavaScript) - -### Combining Filters - -You can combine static parameters with WHERE clauses: - -```json -{ - "adult_male": { - "type": "string", - "x-dynamicEnum": { - "function": "getDynamicChoiceList", - "query": "hh_person", - "params": { - "sex": "male", - "whereClause": "age_from_dob(data.dob) >= 18" - }, - "valueField": "observationId", - "labelField": "data.names" - } - } -} -``` - -Equivalent to: `WHERE data.sex = 'male' AND age_from_dob(data.dob) >= 18` - -## Real-World Examples - -### Example 1: Location Selection - -**Use Case:** Multi-level geographic selection (country → province → village) - -```json -{ - "country": { - "type": "string", - "title": "Select Country", - "x-dynamicEnum": { - "function": "getDynamicChoiceList", - "query": "location", - "params": {}, - "valueField": "data.country_code", - "labelField": "data.country_name", - "distinct": true - } - }, - "province": { - "type": "string", - "title": "Select Province", - "x-dynamicEnum": { - "function": "getDynamicChoiceList", - "query": "location", - "params": { - "country_code": "UG" - }, - "valueField": "data.province_code", - "labelField": "data.province_name", - "distinct": true - } - }, - "village": { - "type": "string", - "title": "Select Village", - "x-dynamicEnum": { - "function": "getDynamicChoiceList", - "query": "location", - "params": { - "country_code": "UG", - "province_code": "central" - }, - "valueField": "data.village_name", - "labelField": "data.village_name", - "distinct": true - } - } -} -``` - -**Note:** Currently, cascading (template parameters like `{{data.field}}`) is not supported. Use static filter values only. - -### Example 2: Household Member Selection (ODK-X "Select Person" Pattern) - -```json -{ - "primary_respondent": { - "type": "string", - "title": "Select Primary Respondent", - "x-dynamicEnum": { - "function": "getDynamicChoiceList", - "query": "hh_person", - "params": {}, - "valueField": "observationId", - "labelField": "data.names", - "distinct": false - } - } -} -``` - -### Example 3: Filtered by Demographics - -```json -{ - "adult_female_participant": { - "type": "string", - "title": "Select Adult Female (18+)", - "x-dynamicEnum": { - "function": "getDynamicChoiceList", - "query": "hh_person", - "params": { - "sex": "female", - "whereClause": "age_from_dob(data.dob) >= 18" - }, - "valueField": "observationId", - "labelField": "data.names", - "distinct": false - } - } -} -``` - -### Example 4: Ranking Survey (ODK-X "Ranking" Pattern) - -```json -{ - "rank_1": { - "type": "string", - "title": "Most Influential Person (Rank #1)", - "x-dynamicEnum": { - "function": "getDynamicChoiceList", - "query": "hh_person", - "params": {"sex": "male"}, - "valueField": "observationId", - "labelField": "data.names", - "distinct": false - } - }, - "rank_2": { - "type": "string", - "title": "Second Most Influential (Rank #2)", - "x-dynamicEnum": { - "function": "getDynamicChoiceList", - "query": "hh_person", - "params": {"sex": "male"}, - "valueField": "observationId", - "labelField": "data.names", - "distinct": false - } - } -} -``` - -### Example 5: Age-Based Filtering - -```json -{ - "adult_participant": { - "type": "string", - "title": "Select Adult (18+)", - "x-dynamicEnum": { - "function": "getDynamicChoiceList", - "query": "hh_person", - "params": { - "whereClause": "age_from_dob(data.dob) >= 18" - }, - "valueField": "observationId", - "labelField": "data.names", - "distinct": false - } - }, - "working_age": { - "type": "string", - "title": "Select Working Age Adult (18-65)", - "x-dynamicEnum": { - "function": "getDynamicChoiceList", - "query": "hh_person", - "params": { - "whereClause": "age_from_dob(data.dob) >= 18 AND age_from_dob(data.dob) <= 65" - }, - "valueField": "observationId", - "labelField": "data.names", - "distinct": false - } - } -} -``` - -## Performance Optimization - -### Use `distinct: true` for Categories - -When showing unique values (e.g., all villages), use `distinct: true`: - -```json -{ - "distinct": true // Returns ["kopria", "lorenkacho"] instead of 100 duplicates -} -``` - -### Filter at Query Level - -Move filtering to the query parameters instead of loading all records: - -```json -// ✅ Good - filters at query time -{ - "params": {"village": "kopria"} -} - -// ❌ Bad - loads all records, filters in UI -{ - "params": {} -} -``` - -### Combine Filters for Precision - -Use multiple filters to reduce dataset size: - -```json -{ - "params": { - "village": "kopria", - "sex": "female", - "whereClause": "age_from_dob(data.dob) >= 18" - } -} -``` - -## Troubleshooting - -### Dropdown is Empty - -**Check 1: Do observations exist?** -- Verify that observations of the queried type exist in the database -- Example: If querying "household", ensure at least one household observation is saved - -**Check 2: Field path is correct?** -- Verify `valueField` and `labelField` match the actual field names in your observations -- Use exact field names with correct `data.` prefix -- Example: Use `data.hh_village_name`, not `data.village` - -**Check 3: Values are populated?** -- Open an observation and verify the field contains data -- If the field is empty in observations, the dropdown will be too - -**Check 4: Filter is too restrictive?** -- Temporarily remove filters to see if results appear -- Relax filters or add observations that match - -**Debug:** Use Chrome DevTools to inspect the WebView and check console logs for errors. - -### Filtered Dropdown Shows All Items - -**Check 1: Filter field names** -- Verify parameter names match database field names -- Example: Using `village_name` but database has `hh_village_name` - -**Check 2: Case sensitivity** -- Filter values are case-sensitive -- "Kopria" ≠ "kopria" - -**Check 3: Filter value exists** -- Verify the filter value actually exists in at least one observation -- If no observations match, the dropdown appears empty - -### Dropdown Shows IDs Instead of Names - -**Solution:** Set `labelField` to a human-readable field: - -```json -{ - "valueField": "observationId", - "labelField": "data.names" // Show names instead of IDs -} -``` - -### Slow Loading - -**Solutions:** -1. Add filters to reduce dataset size -2. Use `distinct: true` for categorical fields -3. Simplify WHERE clauses -4. Target specific form types (don't query all forms) - -## Production Checklist - -Before deploying forms with dynamic choice lists: - -- [ ] `function` is exactly `"getDynamicChoiceList"` -- [ ] `query` matches an existing form type -- [ ] `valueField` path exists in observations -- [ ] `labelField` is human-readable (for users) -- [ ] `distinct` set appropriately (true for categories, false for records) -- [ ] `params` filter fields exist in observations -- [ ] Tested with real data -- [ ] Dropdown populates with expected choices -- [ ] Performance is acceptable (< 1 second load time) -- [ ] Filters work correctly - -## Implementation Details - -### How It Works - -``` -1. Formplayer loads form with x-dynamicEnum config - ↓ -2. DynamicEnumControl renderer initializes - ↓ -3. Calls getDynamicChoiceList via JavaScript bridge - ↓ -4. WebView bridge routes to Formulus native code - ↓ -5. Formulus queries WatermelonDB for observations - ↓ -6. Applies filters (params + whereClause) - ↓ -7. Extracts valueField and labelField from results - ↓ -8. Returns choices to renderer - ↓ -9. Dropdown displays options -``` - -### Key Files - -**Implementation:** -- `formulus-formplayer/src/DynamicEnumControl.tsx` - React renderer component -- `formulus-formplayer/src/builtinExtensions.ts` - Query logic and WHERE clause building -- `formulus/src/webview/FormulusMessageHandlers.ts` - Native message handler -- `formulus/src/services/FormService.ts` - Database query execution - -**Documentation:** -- `DYNAMIC_CHOICE_LISTS.md` - Complete reference in GitHub repo - -## ODK-X Feature Mapping - -Comparison to ODK-X functionality: - -| ODK-X Feature | ODE Implementation | -|---------------|-------------------| -| Linked tables with SQL | `x-dynamicEnum` with `query` + `whereClause` | -| Select person prompt | `query: "hh_person"` with filters | -| Choice/field filters | `params` or `whereClause` | -| `query()` function | `getDynamicChoiceList` | -| `_id` column (primary key) | `observationId` field | -| Cascading selects | **Not supported** (use static filters) | - -## Best Practices - -✅ **Do:** -- Use `distinct: true` for unique values -- Use parameter filtering for simple cases -- Use meaningful `labelField` values -- Test with actual data -- Use `valueField: "observationId"` to reference records -- Use static filter values in params - -❌ **Don't:** -- Query all observations without filtering -- Use `distinct: true` for record IDs -- Forget `data.` prefix for form fields -- Use typos in field names -- Query non-existent form types -- Use template parameters (`{{data.field}}`) - not supported - -## Getting Help - -1. **Check examples above** for similar use case -2. **Review troubleshooting section** for common issues -3. **Check browser console** for error messages -4. **Verify observations** exist with correct field names -5. **Test with simplified schema** first -6. **Ask on GitHub Discussions** if stuck - -## Related Sections - -- [Form Design](/docs/guides/form-design) - Learn about form structure -- [Formplayer Reference](/docs/reference/formplayer) - Question type details -- [Formulus Features](/docs/using/formulus-features) - Mobile app capabilities -- [Sync Protocol](/docs/development/architecture#sync-protocol) - How data syncs +If you bookmarked this URL, use [Choice lists](./choice-lists) going forward. diff --git a/docs/guides/observation-queries.md b/docs/guides/observation-queries.md index d41e4c9..d2a817f 100644 --- a/docs/guides/observation-queries.md +++ b/docs/guides/observation-queries.md @@ -28,7 +28,7 @@ const people = await formulus.getObservationsByQuery({ Supported comparison ops: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`. -**Age from date of birth** (`age_from_dob(...)`) is **not** compiled to SQL. Formplayer evaluates age in JavaScript after fetching (see [Dynamic choice lists](./dynamic-choice-lists.md)). +**Age from date of birth** (`age_from_dob(...)`) is **not** compiled to SQL. Formplayer evaluates age in JavaScript after fetching (see [Choice lists — dynamic filters](./choice-lists#dynamic-choice-lists)). Invalid filters **fail closed** (structured error; no unfiltered fallback). diff --git a/docs/implementer/implementer-index.md b/docs/implementer/implementer-index.md index 5ebc841..5413936 100644 --- a/docs/implementer/implementer-index.md +++ b/docs/implementer/implementer-index.md @@ -138,7 +138,8 @@ Get your first form running in 30 minutes: ### Advanced Form Features - [Conditional Fields](/docs/guides/form-design#conditional-logic) -- [Dynamic Choice Lists](/docs/guides/form-design#dynamic-choices) +- [Choice lists (shared + dynamic)](/docs/guides/choice-lists) +- [Observation queries & indexes](/docs/guides/observation-queries) - [Media Capture (Photos, Audio)](/docs/guides/form-design#media) - [Calculated Fields](/docs/guides/form-design#calculations) diff --git a/sidebars.ts b/sidebars.ts index 881b9e8..b510f7d 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -92,6 +92,7 @@ const sidebars: SidebarsConfig = { 'guides/building-custom-apps-v1', 'guides/building-custom-apps-v2', 'guides/form-design', + 'guides/choice-lists', 'guides/dynamic-choice-lists', 'guides/observation-queries', 'guides/ode-desktop-developer-mode',