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
26 changes: 26 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# EditorConfig — https://editorconfig.org
# Aligns with .gitattributes (LF in repo). Cursor/VS Code apply this on save when
# the EditorConfig extension is enabled (built into Cursor/VS Code by default).

root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2

[*.md]
trim_trailing_whitespace = false

[*.go]
indent_style = tab
indent_size = 4

[*.{bat,cmd}]
end_of_line = crlf

[Makefile]
indent_style = tab
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ flowchart LR
| Profile | Typical focus | Where to work |
|--------|----------------|---------------|
| **Platform developer** | You are editing **this repo**: RN, Go, React, shared packages, CI. | Package `AGENTS.md` below. |
| **Custom app author** | You ship an **HTML/JS/CSS** app bundle and JSON forms for Formulus; you may **not** clone this monorepo. | [Custom app template (AI + author context)](https://github.com/OpenDataEnsemble/custom_app) and [documentation](https://opendataensemble.org/docs/). |
| **Custom app author** | You ship an **HTML/JS/CSS** app bundle and JSON forms for Formulus; you may **not** clone this monorepo. | [Custom app template (AI + author context)](https://github.com/OpenDataEnsemble/custom_app), [documentation](https://opendataensemble.org/docs/), and [FORM_LOCALIZATION_GUIDE.md](FORM_LOCALIZATION_GUIDE.md) for form i18n. |

Do not assume custom app authors have local checkouts of **ODE** or internal example repos.

Expand Down Expand Up @@ -135,13 +135,14 @@ git push origin v1.1.1-alpha.3
- **Custom app bridge (v1.1.0+):** `persistObservation` (headless write), `sync`, `getConnectivityStatus`, `getCurrentDataRevisionCount`, and `openFormplayer` options `skipFinalize` / `skipDraftSelection` — contract in [`FormulusInterfaceDefinition.ts`](formulus/src/webview/FormulusInterfaceDefinition.ts); run `pnpm run sync-interface` in formplayer after changes.
- **Sub-observations:** Each nested Formplayer session validates its own schema; `skipFinalize` only skips the Finalize page. Custom validators are per-session — see [Custom Extensions — nested sessions](https://opendataensemble.org/docs/guides/custom-extensions#nested-sessions-and-custom-validators) (docs site).
- **Shared UI tokens:** Install **tokens** before **components** / **formplayer** where the docs require it (see package READMEs and formplayer AGENTS).
- **i18n (two layers):** ODE-owned locales (`en`/`pt`/`fr`) for Formulus + Formplayer chrome via Settings → Language; form-owned copy via optional `translations` on `ui.json` elements (preprocessed at form init). See [FORM_LOCALIZATION_GUIDE.md](FORM_LOCALIZATION_GUIDE.md) (monorepo) and [form translations guide](https://opendataensemble.org/docs/guides/form-translations) (published).

---

## CI and code quality

- **Pipelines:** [.github/CICD.md](.github/CICD.md).
- **Lint/format:** Run the relevant scripts in the **package you touch** (see root [README.md](README.md) and each package).
- **Lint/format:** Run the relevant scripts in the **package you touch** (see root [README.md](README.md) and each package). On Windows, use LF line endings — root [`.editorconfig`](.editorconfig) and [`.gitattributes`](.gitattributes) match Prettier/CI; run `pnpm run format` in the package if you see `Delete ␍` lint errors.
- **Pre-flight before opening a PR:** each package `AGENTS.md` lists the local `lint` / `format` / `format:check` / `test` / `build` commands that match CI — run them in every package you changed (e.g. [formulus-formplayer/AGENTS.md](formulus-formplayer/AGENTS.md#pre-flight-before-a-pr)).
- **Commits/PRs:** Conventional Commits and PR expectations are documented in [formulus-formplayer/AGENTS.md](formulus-formplayer/AGENTS.md) (project-wide convention).

Expand All @@ -157,4 +158,6 @@ ODE Desktop ships in [`desktop/`](desktop/) (see [desktop/AGENTS.md](desktop/AGE

Authoritative **public** documentation: [opendataensemble.org](https://opendataensemble.org/docs/).

Form localization (embedded `ui.json` translations): [FORM_LOCALIZATION_GUIDE.md](FORM_LOCALIZATION_GUIDE.md).

Optional **AI-focused** context (no ODE clone required): [custom_app](https://github.com/OpenDataEnsemble/custom_app) on GitHub (`README.md`, `AGENTS.md`, `CONTEXT_*.md`).
247 changes: 247 additions & 0 deletions FORM_LOCALIZATION_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
# Form localization guide

How **user-defined forms** (JSON Schema + `ui.json` in custom app bundles) support multiple languages in ODE.

**Audience:** platform developers editing Formplayer/Formulus, and custom app authors shipping forms in app bundles.

**Published user guide:** [Form translations](https://opendataensemble.org/docs/guides/form-translations) on opendataensemble.org (kept in sync conceptually; this file is the monorepo implementation reference).

---

## Two i18n layers (do not mix them up)

| Layer | Who owns it | Where strings live | When it applies |
|-------|-------------|-------------------|-----------------|
| **ODE chrome** | ODE platform | `formulus-formplayer/src/locales/{en,pt,fr}.json`, Formulus i18n catalogs | Next/Back, validation errors, sub-obs “+ Add …” template, loading text |
| **Form copy** | Form / app author | `ui.json` base fields + optional `translations` blocks | Field labels, descriptions, SwipeLayout headers, custom widget copy |

Form authors **do not** edit ODE locale JSON. They embed translations in each form’s `ui.json`. ODE developers extend chrome strings when adding new platform UI.

---

## How form locale is chosen

1. User setting in Formulus **Settings → Language** (`auto`, `en`, `pt`, `fr`)
2. **Auto** → device language; if unsupported, bundle `defaultLocale` from `app.config.json`
3. Fallback `en`

The host passes the resolved tag as **`params.locale`** when opening Formplayer. Formplayer merges form translations **once at init** (`applyFormUiTranslations()` in `formulus-formplayer/src/i18n/applyFormUiTranslations.ts`).

```json
// app/public/app.config.json (custom app bundle)
{
"defaultLocale": "pt"
}
```

Use `defaultLocale` when the study’s primary language is not English so **Auto** prefers the right default on devices whose language ODE does not ship.

---

## Authoring pattern: base string + `translations`

Put the **default** copy on the element (`label`, `description`, `text`, …). Add **`translations.<locale>`** only for locales that differ.

```json
{
"type": "Control",
"scope": "#/properties/participant_name",
"label": "Participant name",
"description": "Full legal name",
"translations": {
"pt": {
"label": "Nome do participante",
"description": "Nome legal completo"
},
"fr": {
"label": "Nom du participant"
}
}
}
```

**Rules:**

- Base `label` is required for every user-visible control — do not rely on `schema.json` `title` for display text in multi-locale forms.
- `translations` keys are **partial**; missing keys fall back to the base string.
- Locale lookup tries the full BCP-47 tag then the language subtag (`pt-BR` → `pt`).
- `translations.<locale>.title` is accepted as an alias for `label` in that block (legacy convenience).

### Portuguese-primary study with English override

Base language can be anything; merge logic is symmetric.

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

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

---

## `schema.title` vs `ui.json` `label`

| | `schema.json` → `properties.*.title` | `ui.json` → `Control.label` |
|---|--------------------------------------|-----------------------------|
| Translated at runtime? | No | Yes |
| Primary on-screen label? | Fallback only | Yes |

```json
// schema.json — keep for validation / exports; not your i18n source of truth
{
"properties": {
"codigo": { "type": "string", "title": "Código" }
}
}
```

```json
// ui.json — what the user actually reads
{
"type": "Control",
"scope": "#/properties/codigo",
"label": "Digitalizar código do envelope",
"translations": { "en": { "label": "Scan the envelope code" } }
}
```

Formplayer resolves labels in order: **`Control.label`** (after translation merge) → JsonForms default → **`schema.title`** → field key. All built-in renderers, SwipeLayout header chips, Finalize summaries, and sub-observation column headers use this path (`controlDisplayText.ts`).

---

## SwipeLayout chrome

Put button and header copy in **`options`**. Override per locale via top-level keys in the translation block (merged into `options`) or nested `translations.<locale>.options`:

```json
{
"type": "SwipeLayout",
"elements": [],
"options": {
"headerTitle": "Household interview",
"nextButtonLabel": "Next",
"finalizeButtonLabel": "Finish"
},
"translations": {
"pt": {
"headerTitle": "Entrevista ao agregado",
"nextButtonLabel": "Seguinte",
"finalizeButtonLabel": "Concluir"
}
}
}
```

---

## Sub-observations

**Add button:** if `options.addButtonLabel` is omitted, Formplayer composes `+ Add {itemLabel}` from schema `itemLabel` using ODE chrome strings (`subObservation.addItem`). Override when you need custom wording:

```json
{
"type": "Control",
"scope": "#/properties/quartos",
"label": "Quartos",
"options": {
"addButtonLabel": "+ Adicionar quarto"
},
"translations": {
"en": {
"addButtonLabel": "+ Add room"
}
}
}
```

**Table columns:** list columns by **`key` only** in `schema.json`. Headers come from the **linked child form**’s `ui.json` labels (after translation), not from hardcoded schema column titles:

```json
"x-subObservation": {
"formType": "censo_milda_quarto",
"itemLabel": "quarto",
"columns": [{ "key": "quarto_num" }, { "key": "quarto_display" }]
}
```

Child forms need their own `Control.label` + `translations` for those fields. Optional static `column.label` / `options.columns[].label` overrides all locales.

---

## Custom question types

Widget-specific copy belongs in **`Control.options`**, not hardcoded in `question_types/.../renderer.js`:

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

Behavioral config (`maxStars`, filters, …) stays in **`schema.json`** (`config` / validation), not in translated UI copy.

---

## Translatable properties (v1)

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

---

## Platform implementation map

| Concern | Location |
|---------|----------|
| Merge `translations` into UI schema at init | `formulus-formplayer/src/i18n/applyFormUiTranslations.ts` |
| Label resolution at render time | `formulus-formplayer/src/utils/controlDisplayText.ts` |
| Form init + `params.locale` | `formulus-formplayer/src/App.tsx` |
| ODE chrome catalogs | `formulus-formplayer/src/locales/*.json` |
| UI locale preference (host) | `formulus/src/lib/locale.ts`, Settings → Language |
| Linked child specs for sub-obs columns | `FormInitData.linkedFormSpecs` (built in Formulus / ODE Desktop) |

When changing merge rules or label resolution, update **`applyFormUiTranslations.test.ts`**, affected renderers, and the [published form translations guide](https://opendataensemble.org/docs/guides/form-translations) in **ode-docs**.

---

## Author checklist

- [ ] Every visible field has `Control.label` in `ui.json` (not only `schema.title`)
- [ ] Base `label` set; `translations` added for other ODE UI locales you ship (`en`, `pt`, `fr`)
- [ ] SwipeLayout headers/buttons translated where needed
- [ ] Sub-obs columns use `key` only; labels live on linked child forms
- [ ] Custom question type strings in `options`, not in renderer JS
- [ ] `defaultLocale` in `app.config.json` when the study default is not English
2 changes: 1 addition & 1 deletion desktop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Developer mode on with missing/invalid folder → blocking error in UI; no silen
## Bridge and bundles

- **Contract source of truth:** [`formulus/src/webview/FormulusInterfaceDefinition.ts`](../formulus/src/webview/FormulusInterfaceDefinition.ts).
- **Form preview:** `formPreviewBridge.ts` handles injection `postMessage` types; device APIs stubbed; observations/attachments use Tauri.
- **Form preview:** `formPreviewBridge.ts` handles injection `postMessage` types; device APIs stubbed; observations/attachments use Tauri. **`linkedFormSpecs`** on `FormInitData` is populated via `buildLinkedFormSpecs` (see `buildFormPreviewInit.ts`) so sub-observation column headers match Formulus.
- **Extensions:** `bundleExtensionLoader.ts` merges `forms/ext.json` like Formulus `ExtensionService`; pass `developerMode` for path prefix.

---
Expand Down
15 changes: 15 additions & 0 deletions desktop/src/lib/__tests__/buildFormPreviewInit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ describe('buildFormPreviewInit', () => {
});
expect(init.subObservationMode).toBe(true);
});

it('forwards linkedFormSpecs when provided', () => {
const linked = {
child: { schema: { type: 'object' }, uiSchema: {} },
};
const init = buildFormPreviewInit({
formType: 'Parent',
params: {},
savedData: {},
formSchema: {},
uiSchema: {},
linkedFormSpecs: linked,
});
expect(init.linkedFormSpecs).toEqual(linked);
});
});

describe('inferObservationIdFromSavedData', () => {
Expand Down
Loading
Loading