Conversation
…ranslations-and-primevue-locales # Conflicts: # package.json # yarn.lock
…ranslations-and-primevue-locales
🦋 Changeset detectedLatest commit: 8d68187 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
…ranslations-and-primevue-locales
| const formLanguage = | ||
| findSavedFormLanguage(langs) ?? findBrowserFormLanguage(langs) ?? langs[0]!; |
There was a problem hiding this comment.
In the next PR, this will be:
| const formLanguage = | |
| findSavedFormLanguage(langs) ?? findBrowserFormLanguage(langs) ?? langs[0]!; | |
| const formLanguage = | |
| findSavedFormLanguage(langs) | |
| ?? findDefaultLang(langs) <--- missing piece | |
| ?? findBrowserFormLanguage(langs) | |
| ?? langs[0]!; |
There was a problem hiding this comment.
I'm not convinced this is the right order. For example if "en" is the default lang, but my browser is "en-NZ" I still want my dates formatted my way. However I do realise it would be weird if the primevue components were translated to one language and the form components were in another.
Perhaps we should use the navigator language if it starts with the form default, so I can use en-NZ if the form default is just en?
| } | ||
|
|
||
| if (!navigator.geolocation) { | ||
| // TODO: translations |
There was a problem hiding this comment.
Not UI text. Only console logging
|
@garethbowen I tried to add as much details as I remembered in the PR description, let me know any questions. I've included a section with follow-ups at the end of the PR description. I plan to resolve these two in the next PR since this one is already quite large.
|
There was a problem hiding this comment.
I considered splitting this file because some parts are beginning to group, but I chose not to since it's still manageable. The logic is only used here, and I preferred to keep everything in one place for now.
garethbowen
left a comment
There was a problem hiding this comment.
This is a great leap forward, thanks!
packages/web-forms/src/components/form-elements/input/geopoint/InputGeopoint.i18n.json
Outdated
Show resolved
Hide resolved
packages/web-forms/src/components/form-elements/input/geopoint/InputGeopoint.i18n.json
Outdated
Show resolved
Hide resolved
| const formLanguage = | ||
| findSavedFormLanguage(langs) ?? findBrowserFormLanguage(langs) ?? langs[0]!; |
There was a problem hiding this comment.
I'm not convinced this is the right order. For example if "en" is the default lang, but my browser is "en-NZ" I still want my dates formatted my way. However I do realise it would be weird if the primevue components were translated to one language and the form components were in another.
Perhaps we should use the navigator language if it starts with the form default, so I can use en-NZ if the form default is just en?
| const isFormEditMode = ref(false); | ||
| provide(IS_FORM_EDIT_MODE, readonly(isFormEditMode)); | ||
| const { setLanguage, formatMessage } = useLocale(computed(() => state.value.root)); | ||
| provide(FORMAT_MESSAGE, formatMessage); |
There was a problem hiding this comment.
I'm not convinced that using provide/inject is necessary here. Why go this route instead of each component just importing the formatMessage function directly?
There was a problem hiding this comment.
It's because of Vue reactivity. The formatMessage function wraps a reactive currentIntl (shallowRef). So, when the locale changes and currentIntl updates, Vue recognizes it needs to re-render the components using it.
If we import this function directly into each child component, Vue wouldn't be able to track it and re-render components.
One way is to pass down the function as a prop to each child component ( <FormQuestion :format-message="formatMessage">). The other way is to use provide/inject to supply it to those who need it.
There was a problem hiding this comment.
I think I've found a cleaner solution using plugins. Based on the plugin docs (which conveniently use a translation plugin as the example): https://vuejs.org/guide/reusability/plugins.html#writing-a-plugin
Instead of providing/injecting the translation function, if you provide a ref to the language string, then the plugin updates reactively. I've tested a basic version that seems to work and looks like this...
import { type App } from "vue";
export default {
install: (app:App, options) => {
app.config.globalProperties.$translate = (key:string) => {
if (options.lang?.value === 'French') {
return 'bonjour ' + key;
} else if (options.lang?.value === 'Español') {
return 'hola ' + key;
} else {
return 'hello ' + key;
}
}
}
}Then in the webFormsPlugin,
const lang = ref('en');
app.provide('lang', lang);
app.use(translateplugin, {lang: lang});Then whenever you want to change the language you inject the ref and update the value...
const lang:Ref<string> = inject('lang')!;
lang.value = language;Then finally to output the string you don't need to inject anything because it's a globally registered plugin:
{{ $translate('greetings.hello') }}I would consider calling it $t but whatever works.
This appears to work, but let me know if I've missed something!
| } | ||
|
|
||
| const props = defineProps<FormLoadErrorProps>(); | ||
| const formatMessage: FormatMessage = inject(FORMAT_MESSAGE)!; |
There was a problem hiding this comment.
Let's come up with a shorter naming convention for formatMessage because it's so widely used.
There was a problem hiding this comment.
The most common name I've seen in libraries (like vue-i18n) is t(...). I renamed it. Let me know what you think :)
…ranslations-and-primevue-locales # Conflicts: # packages/web-forms/src/components/form-elements/upload/UploadImageHeader.vue

Closes #332
First exploratory iteration
I have verified this PR works in these browsers (latest versions):
What else has been done to verify that this works as intended?
I followed this test plan and will send the PR to QA.
Screenshot: form translated in english (Chrome) and spanish (Firefox)
Why is this the best possible solution? Were any other approaches considered?
All user-visible strings in the web-forms package are now localizable, and the active locale automatically follows the form's selected language. PrimeVue component strings (date pickers, dialogs, etc.) are also localized in sync.
How it works?
String authoring using
.i18n.jsonfiles:Strings support ICU message format, including pluralization. Translation strings are defined in
.i18n.jsonfiles placed next to the component that uses them (e.g.MediaBlockBase.i18n.jsonis in components/common/media/). This separation maintains component readability and focus on code.Each entry uses a
component.feature.typekey convention (snake_case) and includes an optionaldeveloper_commentfor translators (Collect and Central usesdeveloper_commenttoo):{ "odk_web_forms.validation.error": { "string": "{count, plural, one {# question with error} other {# questions with errors}}", "developer_comment": "Error banner message. {count} is the number of validation violations." } }Build step:
A
scripts/merge-translations.jsscript (run asbuild:translations, which also runs automatically as part ofbuild) walkssrc/recursively, finds all.i18n.jsonfiles, and merges them intolocales/strings_en.json. The script fails loudly on duplicate keys, preventing accidental collisions across components.The
locales/strings_en.jsonis the single source of truth for English strings and the file that Transifex reads.Transifex integration:
We are doing the same as Central here.
Transifex automatically pulls
strings_en.jsondaily as the source. Before each release, translated files are synced intolocales/strings_<lang>.jsonby executingtx pull -a -f --mode translator. This command references the.tx/configfile to filter by project, and theminimum_perc = 50setting ensures only locales with at least 50% coverage are imported. Thetranslatormode is used primarily to match Central's behavior, but we don't really need it. It pre-fills missing strings with texts fromstrings_en.json.Runtime:
The
src/lib/locale/useLocale.tsis the core runtime composable. It is instantiated once at theOdkWebFormroot component and providessetLanguageandformatMessagefunctions. The latter is shared with other components viaprovide/inject, so child components injectformatMessage, and no plugin globals or wrapper components are used.When the form's available languages change (or on mount),
useLocaleselects the active locale using this priority order:localStorage(key:odk-web-forms-locale)navigator.languages) matched against form languagesBoth saved and browser locale matching support base-language fallback: a browser preference of
fr-CAwill match a form language offrif no exactfr-CAmatch exists.When no form languages are available at all (e.g. during a load error), the browser's locale is used directly, bypassing the saved preference, since there is no language switcher to display, the user cannot change it.
Translation loading:
English strings (
strings_en.json) are bundled eagerly and available immediately. All other locale files load lazily viaimport.meta.glob. When loading a non-English locale, missing keys default to English. Empty strings from Transifex (untranslated entries) are removed during normalization so they don't override the English fallback (we use translator mode when downloading languages from Transifex, so this shouldn't happen, but it's a good safety net).If no translation file exists for a requested locale,
resolveLocaletries the base language first (e.g.enforen-AU) and falls back to English if neither is found.PrimeVue locale
PrimeVue's built-in components (e.g. date pickers) have their own locale strings. These are updated in sync with the active locale using the
primelocalepackage. On each locale change,primevue.config.localeis updated with the matching PrimeVue locale (with the same base-language fallback logic).Form's language switcher
The language switcher is in the
FormHeader, which exposes it as a "change language" event.When the user picks a language from the form's own language switcher,
useLocaleupdates the UI locale, the PrimeVue locale, and callsformRef.value.setLanguage()on the engine root node, all in one call.Why `@formatjs/intl` directly, and not `vue-intl` or `vue-i18n`?
The
vue-i18nwas ruled out because it does not support ICU message format natively, it requires additional parsing processes. ICU is essential here for pluralization, which@formatjs/intlhandles out of the box.The
vue-intlis a thin Vue wrapper around@formatjs/intl. Rather than going through the wrapper, the composable uses@formatjs/intldirectly and exposes it via a customuseLocalecomposable. This was necessary for three reasons:A language change must update the Vue UI strings (via
@formatjs/intl), PrimeVue's internal component strings (viaprimelocale+primevue.config.locale), and the ODK XForms engine (viaformRef.value?.setLanguage()). Thevue-intlonly handles the first of these. We still need a composable to handle the other two, sovue-intlwasn't adding any worth. So a single composable that owns all three updates is the natural fit.Transifex exports
STRUCTURED_JSONwith a nested format{ "key": { "string": "Translated text" } }, rather than the flat{ "key": "Translated text" }that ICU libraries expect. ThenormalizeMessagesfunction handles this flattening on the fly. This step sits inside the composable, before messages are handed to@formatjs/intl.The
vue-intlfollows the global Vue plugin pattern. TheuseLocalecomposable in this PR follows Vue 3 conventions: it is a self-contained, reactive unit of logic that can be instantiated at the form root and composed with the rest of the form-scoped state already managed viaprovide/inject.Why `STRUCTURED_JSON` format on Transifex?
Collect and Central uses developer comments, which are a feature of Transifex. These comments are shown in Transifex's UI while the translator works, providing additional context. For this, we use Transifex's
STRUCTURED_JSONformat that wraps each string value in an object ({ "string": "...", "developer_comment": "..." }). As explained before, thenormalizeMessagesfunction flats this to a proper ICU MessageFormat (key -> value)What's the bundle size after the translation work?
Before:
After:
How does this change affect users? Describe intentional changes to behavior and behavior that could have accidentally been affected by code changes. In other words, what are the regression risks?
They can see the UI translated and enter dates in the calendar using their language's format. The rest of the form should work as expected.
Do we need any specific form for testing your changes? If so, please attach one.
Use forms in Demo app
What's changed
strings_en.jsonfile for Transifex.Following steps
locales/strings_en.jsondaily, which is configured in the project settings on their website, this is the same process that Central follows.