feat(formulus): Add support for i18n in formulus and formplayer#690
Merged
Conversation
najuna-brian
previously approved these changes
Jun 30, 2026
najuna-brian
approved these changes
Jun 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Form localization
This PR adds
How to use it
How user-defined forms (JSON Schema +
ui.jsonin 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 on opendataensemble.org (kept in sync conceptually; this file is the monorepo implementation reference).
Two i18n layers (do not mix them up)
formulus-formplayer/src/locales/{en,pt,fr}.json, Formulus i18n catalogsui.jsonbase fields + optionaltranslationsblocksForm 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
auto,en,pt,fr)defaultLocalefromapp.config.jsonenThe host passes the resolved tag as
params.localewhen opening Formplayer. Formplayer merges form translations once at init (applyFormUiTranslations()informulus-formplayer/src/i18n/applyFormUiTranslations.ts).Use
defaultLocalewhen 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 +
translationsPut the default copy on the element (
label,description,text, …). Addtranslations.<locale>only for locales that differ.{ "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:
labelis required for every user-visible control — do not rely onschema.jsontitlefor display text in multi-locale forms.translationskeys are partial; missing keys fall back to the base string.pt-BR→pt).translations.<locale>.titleis accepted as an alias forlabelin that block (legacy convenience).Portuguese-primary study with English override
Base language can be anything; merge logic is symmetric.
{ "type": "Control", "scope": "#/properties/codigo", "label": "Digitalizar código do envelope", "translations": { "en": { "label": "Scan the envelope code" } } }ptlabel)entranslations.en.labelfr(no block)label)schema.titlevsui.jsonlabelschema.json→properties.*.titleui.json→Control.labelFormplayer 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 intooptions) or nestedtranslations.<locale>.options:{ "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.addButtonLabelis omitted, Formplayer composes+ Add {itemLabel}from schemaitemLabelusing ODE chrome strings (subObservation.addItem). Override when you need custom wording:{ "type": "Control", "scope": "#/properties/quartos", "label": "Quartos", "options": { "addButtonLabel": "+ Adicionar quarto" }, "translations": { "en": { "addButtonLabel": "+ Add room" } } }Table columns: list columns by
keyonly inschema.json. Headers come from the linked child form’sui.jsonlabels (after translation), not from hardcoded schema column titles:Child forms need their own
Control.label+translationsfor those fields. Optional staticcolumn.label/options.columns[].labeloverrides all locales.Custom question types
Widget-specific copy belongs in
Control.options, not hardcoded inquestion_types/.../renderer.js:{ "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 inschema.json(config/ validation), not in translated UI copy.Translatable properties (v1)
Controllabel,description; nestedoptions.*Group,CategorylabelLabeltextSwipeLayout(root)options.headerTitle,options.nextButtonLabel,options.finalizeButtonLabeloptions.addButtonLabel; optionaloptions.columns[].labelPlatform implementation map
translationsinto UI schema at initformulus-formplayer/src/i18n/applyFormUiTranslations.tsformulus-formplayer/src/utils/controlDisplayText.tsparams.localeformulus-formplayer/src/App.tsxformulus-formplayer/src/locales/*.jsonformulus/src/lib/locale.ts, Settings → LanguageFormInitData.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 in ode-docs.Author checklist
Control.labelinui.json(not onlyschema.title)labelset;translationsadded for other ODE UI locales you ship (en,pt,fr)keyonly; labels live on linked child formsoptions, not in renderer JSdefaultLocaleinapp.config.jsonwhen the study default is not English