Skip to content

Commit 50cb58f

Browse files
committed
✨ Allow deployment config to provide translation overrides
A generic way for deployments to tweak any i18nized text in the app, specified as "translation_overrides": {"<lang>": { ...same shape as en.json... }} Via i18next addResourceBundle, these get tacked onto the translations from en.json, es.json, etc. when the config is loaded, which is before the UI gets a change to load any strings that would be modified. The exception is, of course, the strings that appear before the user enters their opcode (as the app doesn't know what deployment it's using yet!) We can likely use this to replace intro.translated_text entirely, since it accomplishes the same thing in a more generic way. We might also consider using it to replace label-options "translations"; i.e. instead of asking deployers to provide the translations there for all the modes they list, we provide the translations for our default set of modes, and they would only have to provide the ones they added (or wanted to name something else), and those translations would live in the same place as any other translations/ in-app text they are providing.
1 parent 1e77c85 commit 50cb58f

4 files changed

Lines changed: 100 additions & 4 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import i18next, { applyConfigTranslations } from '../js/i18nextInit';
2+
import { DeploymentConfigWithOverrides } from '../js/types/appConfigTypes';
3+
4+
const configWithOverrides = {
5+
translation_overrides: {
6+
en: { library: { 'available-vehicles': { title: 'Available Bikes' } } },
7+
},
8+
} as unknown as DeploymentConfigWithOverrides;
9+
10+
describe('applyConfigTranslations', () => {
11+
afterEach(() => applyConfigTranslations(null));
12+
13+
it('overrides a built-in string with the one from the config', () => {
14+
applyConfigTranslations(configWithOverrides);
15+
expect(i18next.t('library.available-vehicles.title')).toBe('Available Bikes');
16+
});
17+
18+
it('leaves keys that the config does not mention alone', () => {
19+
applyConfigTranslations(configWithOverrides);
20+
expect(i18next.t('library.available-vehicles.subtitle')).toBe('Find a station near you');
21+
expect(i18next.t('library.available-vehicles.scan')).toBe('Scan');
22+
});
23+
24+
it('only overrides the languages given in the config', () => {
25+
applyConfigTranslations({
26+
translation_overrides: { en: { loading: 'Just a sec...' } },
27+
} as unknown as DeploymentConfigWithOverrides);
28+
expect(i18next.getResource('en', 'translation', 'loading')).toBe('Just a sec...');
29+
expect(i18next.getResource('es', 'translation', 'loading')).toBe('Cargando...');
30+
});
31+
32+
it('restores the built-in strings once a config without overrides is applied', () => {
33+
applyConfigTranslations(configWithOverrides);
34+
applyConfigTranslations({} as DeploymentConfigWithOverrides);
35+
expect(i18next.t('library.available-vehicles.title')).toBe('Available Vehicles');
36+
});
37+
});

src/js/config/dynamicConfig.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from './opcode';
1212
import { Alerts } from '../components/AlertArea';
1313
import { setPendingOpcode } from '../onboarding/onboardingHelper';
14+
import { applyConfigTranslations } from '../i18nextInit';
1415

1516
export const CONFIG_PHONE_UI = 'config/app_ui_config';
1617
export const CONFIG_PHONE_UI_KVSTORE = 'CONFIG_PHONE_UI';
@@ -168,6 +169,7 @@ export function loadNewConfig(newToken: string, existingVersion?: number): Promi
168169
.then(([result, kvStoreResult]) => {
169170
logDebug(`UI_CONFIG: Stored dynamic config in KVStore successfully,
170171
result = ${JSON.stringify(kvStoreResult)}`);
172+
applyConfigTranslations(downloadedConfig);
171173
_promisedConfig = Promise.resolve(downloadedConfig);
172174
configChanged = true;
173175
return true;
@@ -231,8 +233,13 @@ export function getConfig(): Promise<DeploymentConfig | null> {
231233
},
232234
);
233235
});
234-
_promisedConfig = promise;
235-
return promise;
236+
// apply translation overrides before anyone awaiting the config can render with the defaults
237+
const configPromise = promise.then((config: DeploymentConfig | null) => {
238+
applyConfigTranslations(config);
239+
return config;
240+
});
241+
_promisedConfig = configPromise;
242+
return configPromise;
236243
}
237244

238245
export async function refreshConfig(opcode: string, existingVersion?: number) {

src/js/i18nextInit.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import i18next from 'i18next';
66
import { initReactI18next } from 'react-i18next';
7+
import { DeploymentConfigWithOverrides, TranslationTree } from './types/appConfigTypes';
78

89
/* How should we handle missing translations?
910
@@ -38,11 +39,51 @@ function mergeInTranslations(lang, fallbackLang) {
3839

3940
import enJson from '../i18n/en.json';
4041
import esJson from '../../locales/es/i18n/es.json';
42+
/* the built-in translations, before any deployment config overrides are applied */
43+
const baseTranslations: { [lang: string]: TranslationTree } = {
44+
en: enJson as TranslationTree,
45+
es: mergeInTranslations(esJson, enJson) as TranslationTree,
46+
};
4147
const langs = {
42-
en: { translation: enJson },
43-
es: { translation: mergeInTranslations(esJson, enJson) },
48+
en: { translation: baseTranslations.en },
49+
es: { translation: baseTranslations.es },
4450
};
4551

52+
/* warns about override keys that don't exist in the built-in translations, which are
53+
almost always typos since they would silently have no effect */
54+
function warnOnUnknownKeys(overrides: TranslationTree, base: TranslationTree, path = '') {
55+
Object.entries(overrides).forEach(([key, value]) => {
56+
const keyPath = path ? `${path}.${key}` : key;
57+
if (base[key] === undefined) {
58+
logWarn(`Deployment config overrides unknown translation key '${keyPath}'`);
59+
} else if (typeof value === 'object' && typeof base[key] === 'object') {
60+
warnOnUnknownKeys(value, base[key] as TranslationTree, keyPath);
61+
}
62+
});
63+
}
64+
65+
/**
66+
* @description Applies the `translation_overrides` from a deployment config on top of the
67+
* built-in translations. Only the languages given in the config are overridden; every other
68+
* language, and every key not mentioned, keeps its built-in value.
69+
*/
70+
export function applyConfigTranslations(config?: DeploymentConfigWithOverrides | null) {
71+
const overrides = config?.translation_overrides;
72+
Object.keys(baseTranslations).forEach((lang) => {
73+
/* a deep addResourceBundle mutates the bundle in place, so drop the existing bundle and start
74+
from a copy of the built-in translations; otherwise overrides from a previously loaded
75+
deployment config would stick around */
76+
const base = JSON.parse(JSON.stringify(baseTranslations[lang]));
77+
i18next.removeResourceBundle(lang, 'translation');
78+
i18next.addResourceBundle(lang, 'translation', base, false, true);
79+
if (overrides?.[lang]) {
80+
// always validate against English, which is the complete set of keys
81+
warnOnUnknownKeys(overrides[lang], baseTranslations.en);
82+
i18next.addResourceBundle(lang, 'translation', overrides[lang], true, true);
83+
}
84+
});
85+
}
86+
4687
const locales = navigator?.languages?.length ? navigator.languages : [navigator.language];
4788
let detectedLang;
4889
for (const locale of locales) {

src/js/types/appConfigTypes.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { DeploymentConfig } from 'op-deployment-configs';
2+
3+
export type TranslationTree = { [key: string]: string | TranslationTree };
4+
5+
/** Per-language translation overrides supplied by a deployment config, keyed by language code */
6+
export type TranslationOverrides = { [lang: string]: TranslationTree };
7+
8+
// `DeploymentConfig` is a type alias in op-deployment-configs, so it can't be augmented in place
9+
export type DeploymentConfigWithOverrides = DeploymentConfig & {
10+
translation_overrides?: TranslationOverrides;
11+
};

0 commit comments

Comments
 (0)