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
4 changes: 4 additions & 0 deletions docs/guides/form-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Complete guide to designing forms in ODE using JSON schema and JSON Forms.

## Overview

ODE forms support optional **embedded translations** in `ui.json` (form-owned copy) separate from **ODE platform locales** (Formulus Settings → Language). For multi-locale forms, put display strings on `Control.label` / `Label.text` with a `translations` block — see [Form translations](/guides/form-translations).

Forms in ODE are defined using JSON schema, following the JSON Forms specification. A form consists of two main components:

1. **Schema**: Defines the data structure and validation rules
Expand Down Expand Up @@ -44,6 +46,8 @@ UI Schema (JSON Forms UI Schema) controls the **presentation layer**:
- **Conditional Logic**: When fields are shown or hidden
- **Field Configuration**: Labels, placeholders, and display options

For **multi-locale** forms, user-visible labels belong on `Control.label` in `ui.json` (with optional `translations`), not on `schema.json` `title`. See [Schema `title` vs UI `label`](/guides/form-translations#schema-title-vs-ui-label) in the form translations guide.

### The Role of Formplayer

Formplayer is the React-based rendering engine that:
Expand Down
204 changes: 204 additions & 0 deletions docs/guides/form-translations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# Form translations (ui.json)

ODE supports **two independent i18n layers**:

1. **ODE i18n** — Formulus shell and Formplayer chrome (buttons, validation messages, loading text). Managed via ODE locale catalogs (`en`, `pt`, `fr` in v1) and **Settings → Language** in Formulus.
2. **Form i18n** — Labels and display strings **authored in your form bundle**, embedded directly in `ui.json`. No external translation files per form.

This guide covers **form i18n** (layer 2).

## Schema `title` vs UI `label`

ODE forms use **two** places where a field can get a human-readable name. They are not interchangeable for multi-locale forms.

| | `schema.json` → `properties.<field>.title` | `ui.json` → `Control.label` (+ `translations`) |
|---|---------------------------------------------|------------------------------------------------|
| **Purpose** | Data contract, validation messages, exports, tooling | What the user sees on screen |
| **Translated at runtime?** | No | Yes — merged once at form init from `params.locale` |
| **When Formplayer uses it** | Fallback when no `Control.label` is set | Primary display label (all locales) |
| **Authoring for multi-locale** | Keep stable; one language is fine (e.g. PT for a PT study) | Put **every** locale the user should see here |

**Rule of thumb:** for any field the user reads in the form, set `Control.label` in `ui.json`. Use `schema.title` for schema semantics and as a last-resort fallback only.

### How Formplayer picks the displayed label

At form init, `applyFormUiTranslations()` merges `translations.<locale>` into the UI schema for the active locale. At render time, Formplayer resolves labels in this order:

1. **`Control.label`** on the UI schema node (after translation merge)
2. JsonForms-derived label (often mirrors `schema.title` when step 1 is missing)
3. **`schema.title`** for that property
4. Field key

All built-in renderers, SwipeLayout **`headerFields`**, Finalize summaries, and sub-observation column headers use this same resolution — not ad-hoc `schema.title` reads in each widget.

**Why this matters:** if locale A gets its copy from `ui.label` but locale B falls back to `schema.title` (because base `label` was omitted), you get **different text and sometimes different layout** (inline vs block spacing) even though the form “looks translated.” Always set a base `label` on controls you translate.

### Layout and label length

SwipeLayout forms often use **inline** rows (label left, control right). **Block** controls (QR scan, photo, signature, etc.) sit below inline neighbours with top padding to align with the row grid.

Row height follows label and value content. A longer label on the row above a block control (even on a single line) increases vertical space before the next field. That is normal layout rhythm, not a sign that translation used a different code path.

## Why embedded `translations` (not JsonForms `i18n` keys)

JSON Forms supports an `i18n` prop with translation **keys** and external catalogs. ODE form translations intentionally use inline locale blocks instead:

- **Portability** — a form zip is self-contained.
- **Offline-first** — no dependency on bundle-wide catalogs for field copy.
- **Performance** — translations are merged **once at form init**, not on every render.
- **Partial coverage** — translate only the fields you need; others fall back to defaults.

ODE platform chrome **does** use JsonForms `i18n` internally.

## Basic shape

On any `ui.json` element, add an optional `translations` object keyed by locale (BCP-47, e.g. `en`, `pt`, `fr`, `pt-BR`):

```json
{
"type": "Control",
"scope": "#/properties/participant_name",
"label": "Participant name",
"description": "Enter the participant's full name",
"translations": {
"fr": {
"label": "Nom du participant",
"description": "Saisissez le nom complet"
}
}
}
```

**Authoring tip:** always set a base `label` on `Control` elements you translate — in **any** authoring language (English, Portuguese, etc.). The base string is used when the active UI locale has no matching block in `translations`. If only `translations.<locale>.label` is set and base `label` is missing, some locales fall back to `schema.title` while others use `ui.label`, which produces inconsistent copy and layout.

`translations.<locale>.title` is accepted as an alias for `label` when `label` is omitted in that locale block (legacy / migration convenience).

### English base + Portuguese override

```json
{
"type": "Control",
"scope": "#/properties/codigo",
"label": "Scan the envelope code",
"translations": {
"pt": { "label": "Digitalizar código do envelope" }
}
}
```

### Portuguese base + English override

The merge logic is **symmetric** — base locale is whatever you put on `label`; `translations` keys are independent of that choice.

```json
{
"type": "Control",
"scope": "#/properties/codigo",
"label": "Digitalizar código do envelope",
"translations": {
"en": { "label": "Scan the envelope code" }
}
}
```

| Active UI locale | Displayed label |
|------------------|-----------------|
| `pt` | Base PT (`label`) |
| `en` | `translations.en.label` |
| `fr` (no `translations.fr`) | Base PT (`label`) |

For a Portuguese-primary study, set `"defaultLocale": "pt"` in `app.config.json` so **Settings → Language → Auto** prefers PT when the device language is not in the ODE catalog (`en`, `pt`, `fr`).

**Rules:**

- `translations` is optional — forms without it behave exactly as before.
- Keys under `translations[<locale>]` are **partial** — only listed properties are overridden.
- Unknown locale → default strings are used (no error).
- Locale lookup tries the full tag then the language subtag (`pt-BR` → `pt`).

## Translatable properties (v1)

| Element | Properties |
|---------|------------|
| `Control` | `label`, `description`; nested `options.*` for widget copy |
| `Group`, `Category` | `label` |
| `Label` | `text` |
| `SwipeLayout` (root) | `options.headerTitle`, `options.nextButtonLabel`, `options.finalizeButtonLabel` |
| Sub-observation | `options.addButtonLabel`; optional static `options.columns[].label` override |

When `addButtonLabel` is omitted, Formplayer composes the add button from the schema `itemLabel` using ODE locale strings (`subObservation.addItem`, `subObservation.addingItem`). Override per locale with `translations.<locale>.addButtonLabel` (merged into `options`) or `translations.<locale>.options.addButtonLabel`.

`schema.json` titles are **not** mutated at runtime. They remain the fallback when no `Control.label` override exists. For multi-locale forms, put display strings in `ui.json`.

### Sub-observation table columns

In `schema.json`, list columns by `key` only:

```json
"columns": [{ "key": "quarto_num" }, { "key": "quarto_display" }]
```

Formplayer resolves headers from the **linked child form** (loaded at init as `linkedFormSpecs`): child `ui.json` `Control.label` (after translation), then child `schema.title`. Optional `column.label` in schema or `options.columns` is a static override for all locales.

Swipe layout `options.headerFields` chips use the same resolution order as on-screen controls.

## Custom question types

Use the **same** `Control` + `translations` pattern. Put widget-specific copy in `Control.options`; the renderer receives it via the `options` prop:

```json
{
"type": "Control",
"scope": "#/properties/confidence",
"label": "How confident are you?",
"options": {
"lowLabel": "Not at all",
"oneOf": [{ "const": "yes", "title": "Yes" }]
},
"translations": {
"pt": {
"label": "Qual é a sua confiança?",
"options": {
"lowLabel": "Nada",
"oneOf": [{ "const": "yes", "title": "Sim" }]
}
}
}
}
```

Do not hardcode user-visible strings in `renderer.js`. Behavioral config (`maxStars`, filters) stays in `schema.json` as `config`.

## HTML and handlebars

- HTML in translated strings is allowed; sanitization runs at **render time** (unchanged).
- If the default uses `{{data.village}}`, repeat placeholders in each locale that needs interpolation.

## Active UI locale

Resolved in Formulus **Settings → Language** (Auto / English / Português / Français):

1. Explicit setting, or **Auto** → device language
2. Bundle `defaultLocale` in `app.config.json` (when Auto and device unsupported)
3. Fallback `en`

Formplayer receives `params.locale` from the host. ODE Desktop Form Preview uses the same preference (shared `@ode/uiLocale` storage key).

## Migration from schema-only labels

Studies that author Portuguese (or other languages) in `schema.json` `title` keep working for **single-locale** forms with no `ui.json` `Control` entries.

To add multi-locale support:

1. Add a `Control` for each visible field in `ui.json` (if not already present).
2. Set **`label`** to your authoring language (the default when no `translations` block matches).
3. Add **`translations`** for other ODE UI locales (`en`, `pt`, `fr`).
4. Leave `schema.title` in place for validation/export; do not rely on it for on-screen copy.

**Checklist — avoid mixed label sources:**

- [ ] Every user-visible field has `Control.label` in `ui.json`
- [ ] Base `label` is set (not only `translations.<locale>.label`)
- [ ] SwipeLayout chrome (`headerTitle`, button labels) uses base + `translations` where needed
- [ ] Sub-obs columns use `key` only; labels come from linked child forms
- [ ] Custom question type copy lives in `Control.options`, not hardcoded in `renderer.js`
47 changes: 37 additions & 10 deletions docs/reference/configuration/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,49 @@ sidebar_position: 2

# Client Configuration

Configuration options for ODE clients.
Configuration options for ODE clients (Formulus, Formplayer, ODE Desktop).

## Overview
## UI language (Formulus)

[Description placeholder]
**Settings → Language** controls ODE-owned UI strings (shell, Formplayer chrome, validation messages).

## Formulus Configuration
| Option | Behavior |
|--------|----------|
| **Auto (device)** | Use device language when an ODE catalog exists (`en`, `pt`, `fr` in v1) |
| **English / Português / Français** | Fixed UI locale |

[Description placeholder]
**Precedence** when resolving the active locale for Formplayer:

## Custom Application Configuration
1. Formulus Settings (or ODE Desktop Form Preview language control)
2. If Auto → device language
3. Custom app `app.config.json` → `defaultLocale` (optional)
4. Fallback `en`

[Description placeholder]
The resolved locale is passed to Formplayer as `params.locale`. It is **not** stored on observation JSON.

## Related Content
Advanced: `openFormplayer(formType, observationId, { locale: 'pt', ... })` may override for a single session.

- [Formulus Configuration](/guides/configuration)
- [Custom Applications](/guides/custom-applications)
## Custom application configuration

Optional `defaultLocale` in [`app.config.json`](/guides/configuration):

```json
{
"name": "My Study",
"version": "1.0.0",
"defaultLocale": "pt",
"theme": { ... }
}
```

Used when the user selects **Auto** and the device language is not in ODE catalogs.

## Form-level translations

Form field labels are **not** configured here. Authors embed optional `translations` objects in each form's `ui.json`. See [Form translations](/guides/form-translations).

## Related content

- [Form translations](/guides/form-translations)
- [Configuration](/guides/configuration)
- [Custom applications](/guides/custom-applications)
19 changes: 18 additions & 1 deletion docs/reference/formplayer-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,9 +408,26 @@ addFormats(ajv); // Standard format validators (date, email, etc.)
- `scope`: JSON Pointer to schema property (must start with `#/properties/`)

**Optional:**
- `label`: String or `false` (to hide label)
- `label`: String or `false` (to hide label). **Primary on-screen label** for multi-locale forms; merged with `translations` at init. When omitted, Formplayer falls back to `schema.title` — see [Form translations — label resolution](/guides/form-translations#how-formplayer-picks-the-displayed-label).
- `options`: Object with renderer-specific options (see below)
- `rule`: Conditional display/enable rule
- `translations`: Optional per-locale overrides (see [Form translations](/guides/form-translations))

**`translations` (form i18n):**

```json
{
"type": "Control",
"scope": "#/properties/name",
"label": "Name",
"translations": {
"fr": { "label": "Nom" },
"pt": { "label": "Nome" }
}
}
```

Partial keys are allowed. Merged once at form init from `params.locale`. Not persisted as observation data.

**Common `options`:**

Expand Down
1 change: 1 addition & 0 deletions sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ const sidebars: SidebarsConfig = {
'guides/building-custom-apps-v1',
'guides/building-custom-apps-v2',
'guides/form-design',
'guides/form-translations',
'guides/choice-lists',
'guides/dynamic-choice-lists',
'guides/observation-queries',
Expand Down
Loading