@openmrs/esm-implementer-tools-app 11.0.1-pre.6000 → 11.0.1-pre.6010
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/1492.js +1 -0
- package/dist/1492.js.map +1 -0
- package/dist/1695.js +1 -0
- package/dist/1695.js.map +1 -0
- package/dist/{3038.css.map → 6479.css.map} +1 -1
- package/dist/6479.js +1 -0
- package/dist/6479.js.map +1 -0
- package/dist/openmrs-esm-implementer-tools-app.js +2 -2
- package/dist/openmrs-esm-implementer-tools-app.js.map +1 -1
- package/dist/routes.json +1 -1
- package/package.json +3 -3
- package/src/implementer-tools.component.tsx +3 -0
- package/src/translations/i18next-loads.ts +166 -0
- package/src/translations/translation-catalog.test.ts +306 -0
- package/src/translations/translation-catalog.ts +302 -0
- package/src/translations/translation-exports.test.ts +42 -0
- package/src/translations/translation-exports.ts +53 -0
- package/src/translations/translation-overrides.test.ts +473 -0
- package/src/translations/translation-overrides.ts +197 -0
- package/dist/3038.js +0 -1
- package/dist/3038.js.map +0 -1
- package/dist/4012.js +0 -1
- package/dist/4012.js.map +0 -1
- package/dist/8855.js +0 -1
- package/dist/8855.js.map +0 -1
- /package/dist/{3038.css → 6479.css} +0 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { act, renderHook } from '@testing-library/react';
|
|
3
|
+
import i18next from 'i18next';
|
|
4
|
+
import { useSession } from '@openmrs/esm-framework';
|
|
5
|
+
import { implementerToolsConfigStore } from '@openmrs/esm-framework/src/internal';
|
|
6
|
+
import {
|
|
7
|
+
findOverrideSource,
|
|
8
|
+
flattenBundle,
|
|
9
|
+
getLoadedNamespaces,
|
|
10
|
+
loadTranslationLanguage,
|
|
11
|
+
loadTranslationNamespace,
|
|
12
|
+
useTranslationEntries,
|
|
13
|
+
useTranslationLanguages,
|
|
14
|
+
} from './translation-catalog';
|
|
15
|
+
|
|
16
|
+
vi.mock('../hooks', () => ({ useFrontendModules: () => [] }));
|
|
17
|
+
|
|
18
|
+
describe('flattenBundle', () => {
|
|
19
|
+
it('keeps the top-level string entries', () => {
|
|
20
|
+
expect(flattenBundle({ loginButton: 'Log in', locationsHeader: 'Locations' })).toEqual({
|
|
21
|
+
loginButton: 'Log in',
|
|
22
|
+
locationsHeader: 'Locations',
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('leaves out a nested entry, which could be listed but never overridden', () => {
|
|
27
|
+
// An override is written at the literal path given, while i18next resolves `errors.required` by
|
|
28
|
+
// walking `errors` then `required`, so editing one here could never take effect.
|
|
29
|
+
expect(flattenBundle({ loginButton: 'Log in', errors: { required: 'Required' } })).toEqual({
|
|
30
|
+
loginButton: 'Log in',
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('drops leaves that an override could not represent', () => {
|
|
35
|
+
expect(flattenBundle({ ok: 'Fine', list: ['a', 'b'], count: 3 })).toEqual({ ok: 'Fine' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('tolerates a missing bundle', () => {
|
|
39
|
+
expect(flattenBundle(undefined)).toEqual({});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('getLoadedNamespaces', () => {
|
|
44
|
+
it('lists the loaded namespaces, hiding extension-scoped ones', () => {
|
|
45
|
+
window.i18next = {
|
|
46
|
+
...window.i18next,
|
|
47
|
+
store: {
|
|
48
|
+
data: {
|
|
49
|
+
en: {
|
|
50
|
+
core: {},
|
|
51
|
+
'@openmrs/esm-login-app': {},
|
|
52
|
+
'@openmrs/esm-login-app___some-slot___some-extension': {},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
} as unknown as typeof window.i18next;
|
|
57
|
+
|
|
58
|
+
expect(getLoadedNamespaces('en')).toEqual(['core', '@openmrs/esm-login-app']);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns nothing for a language with no resources', () => {
|
|
62
|
+
expect(getLoadedNamespaces('km')).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('findOverrideSource', () => {
|
|
67
|
+
const overrides = {
|
|
68
|
+
en: {
|
|
69
|
+
loginButton: { _value: 'Sign in', _source: 'temporary config' },
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
it('reports the config an override came from', () => {
|
|
74
|
+
expect(findOverrideSource(overrides, 'en', 'loginButton')).toBe('temporary config');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('reports nothing for a key, language, or namespace that is not overridden', () => {
|
|
78
|
+
expect(findOverrideSource(overrides, 'en', 'locationsHeader')).toBeUndefined();
|
|
79
|
+
expect(findOverrideSource(overrides, 'km', 'loginButton')).toBeUndefined();
|
|
80
|
+
expect(findOverrideSource(undefined, 'en', 'loginButton')).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('ignores the schema’s own default, which is not a per-key leaf', () => {
|
|
84
|
+
expect(findOverrideSource({ _value: {}, _source: 'default' }, 'en', 'loginButton')).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('loadTranslationNamespace', () => {
|
|
89
|
+
it('also loads the fallback languages the namespace is missing, which the table lists keys from', async () => {
|
|
90
|
+
const loaded = new Set(['en']);
|
|
91
|
+
const reloadResources = vi.fn((lngs: Array<string>, _ns: Array<string>, done: () => void) => {
|
|
92
|
+
lngs.forEach((lng) => loaded.add(lng));
|
|
93
|
+
done();
|
|
94
|
+
});
|
|
95
|
+
window.i18next = {
|
|
96
|
+
...window.i18next,
|
|
97
|
+
reloadResources,
|
|
98
|
+
hasResourceBundle: (lng: string) => loaded.has(lng),
|
|
99
|
+
services: { languageUtils: { toResolveHierarchy: () => ['fr-CA', 'fr', 'en'] } },
|
|
100
|
+
} as unknown as typeof window.i18next;
|
|
101
|
+
|
|
102
|
+
await loadTranslationNamespace('@openmrs/esm-login-app', 'fr-CA');
|
|
103
|
+
|
|
104
|
+
// `en` is already loaded; `fr-CA` is always fetched, since it is the language being browsed.
|
|
105
|
+
expect(reloadResources).toHaveBeenCalledWith(['fr-CA', 'fr'], ['@openmrs/esm-login-app'], expect.any(Function));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('with i18next’s record of each load', () => {
|
|
109
|
+
const namespace = '@openmrs/esm-login-app';
|
|
110
|
+
let state: Record<string, number>;
|
|
111
|
+
let loaded: Set<string>;
|
|
112
|
+
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
state = {};
|
|
115
|
+
loaded = new Set();
|
|
116
|
+
window.i18next = {
|
|
117
|
+
...window.i18next,
|
|
118
|
+
hasResourceBundle: (lng: string, ns: string) => loaded.has(`${lng}|${ns}`),
|
|
119
|
+
services: { backendConnector: { state }, languageUtils: { toResolveHierarchy: () => ['en'] } },
|
|
120
|
+
} as unknown as typeof window.i18next;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('retries a namespace that failed before, which i18next would otherwise skip', async () => {
|
|
124
|
+
state[`en|${namespace}`] = -1;
|
|
125
|
+
window.i18next.reloadResources = vi.fn((_lngs, _ns, done) => {
|
|
126
|
+
// i18next skips a pair still marked failed, so the load only happens if the mark was cleared.
|
|
127
|
+
if (state[`en|${namespace}`] >= 0) {
|
|
128
|
+
state[`en|${namespace}`] = 2;
|
|
129
|
+
loaded.add(`en|${namespace}`);
|
|
130
|
+
}
|
|
131
|
+
done?.();
|
|
132
|
+
}) as unknown as typeof window.i18next.reloadResources;
|
|
133
|
+
|
|
134
|
+
await expect(loadTranslationNamespace(namespace, 'en')).resolves.toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('reports a load that failed even though i18next called back without an error', async () => {
|
|
138
|
+
window.i18next.reloadResources = vi.fn((_lngs, _ns, done) => {
|
|
139
|
+
state[`en|${namespace}`] = -1;
|
|
140
|
+
done?.();
|
|
141
|
+
}) as unknown as typeof window.i18next.reloadResources;
|
|
142
|
+
|
|
143
|
+
await expect(loadTranslationNamespace(namespace, 'en')).rejects.toThrow(/failed; try again/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('ignores an error from another namespace’s load', async () => {
|
|
147
|
+
window.i18next.reloadResources = vi.fn((_lngs, _ns, done) => {
|
|
148
|
+
state[`en|${namespace}`] = 2;
|
|
149
|
+
loaded.add(`en|${namespace}`);
|
|
150
|
+
done?.([new Error('core failed')]);
|
|
151
|
+
}) as unknown as typeof window.i18next.reloadResources;
|
|
152
|
+
|
|
153
|
+
await expect(loadTranslationNamespace(namespace, 'en')).resolves.toBeUndefined();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('when a read never finishes', () => {
|
|
157
|
+
beforeEach(() => vi.useFakeTimers());
|
|
158
|
+
afterEach(() => vi.useRealTimers());
|
|
159
|
+
|
|
160
|
+
it('gives up, and lets the next attempt start a read of its own', async () => {
|
|
161
|
+
window.i18next.reloadResources = vi.fn(() => {
|
|
162
|
+
state[`en|${namespace}`] = 1;
|
|
163
|
+
}) as unknown as typeof window.i18next.reloadResources;
|
|
164
|
+
|
|
165
|
+
const hung = loadTranslationNamespace(namespace, 'en');
|
|
166
|
+
const assertion = expect(hung).rejects.toThrow(/did not finish/);
|
|
167
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
168
|
+
await assertion;
|
|
169
|
+
|
|
170
|
+
// Left marked as loading, i18next would attach the next attempt to the read that never ended.
|
|
171
|
+
expect(state[`en|${namespace}`]).toBeUndefined();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('gives up on loading a language whose namespace read hangs', async () => {
|
|
175
|
+
loaded.add(`fr|${namespace}`);
|
|
176
|
+
window.i18next.languages = ['fr'];
|
|
177
|
+
window.i18next.store = { data: { fr: { [namespace]: {} } } } as unknown as typeof window.i18next.store;
|
|
178
|
+
window.i18next.reloadResources = vi.fn(() => {
|
|
179
|
+
state[`de|${namespace}`] = 1;
|
|
180
|
+
}) as unknown as typeof window.i18next.reloadResources;
|
|
181
|
+
|
|
182
|
+
const hung = loadTranslationLanguage('de');
|
|
183
|
+
const assertion = expect(hung).rejects.toThrow(/did not finish/);
|
|
184
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
185
|
+
await assertion;
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('useTranslationEntries', () => {
|
|
192
|
+
async function setUpI18next(lng: string, resources: Record<string, Record<string, Record<string, string>>>) {
|
|
193
|
+
const i18n = i18next.createInstance();
|
|
194
|
+
await i18n.init({ lng, fallbackLng: 'en', nsSeparator: false, resources });
|
|
195
|
+
window.i18next = i18n as unknown as typeof window.i18next;
|
|
196
|
+
implementerToolsConfigStore.setState({ config: {} });
|
|
197
|
+
return i18n;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
it('lists every plural form the selected language renders through, even ones no bundle has', async () => {
|
|
201
|
+
const i18n = await setUpI18next('ar', {
|
|
202
|
+
en: { mod: { items_one: '{{count}} item', items_other: '{{count}} items' } },
|
|
203
|
+
ar: { mod: {} },
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const { result } = renderHook(() => useTranslationEntries(['mod'], 'ar'));
|
|
207
|
+
const listed = result.current.filter((entry) => entry.key.startsWith('items_')).map((entry) => entry.key);
|
|
208
|
+
|
|
209
|
+
// Overriding every listed form must change what Arabic renders for any count.
|
|
210
|
+
i18n.addResourceBundle('ar', 'mod', Object.fromEntries(listed.map((key) => [key, 'AR'])), true, true);
|
|
211
|
+
|
|
212
|
+
for (const count of [0, 1, 2, 3, 11, 100]) {
|
|
213
|
+
expect(i18n.t('items', { ns: 'mod', lng: 'ar', count })).toBe('AR');
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('recomputes for bundles it lists, but not for other namespaces or languages', async () => {
|
|
218
|
+
const i18n = await setUpI18next('fr', { en: { mod: { save: 'Save' } }, fr: { mod: {} } });
|
|
219
|
+
|
|
220
|
+
const { result } = renderHook(() => useTranslationEntries(['mod'], 'fr'));
|
|
221
|
+
const initial = result.current;
|
|
222
|
+
|
|
223
|
+
act(() => {
|
|
224
|
+
i18n.addResourceBundle('fr', 'mod___slot___extension', { save: 'Ext' });
|
|
225
|
+
i18n.addResourceBundle('de', 'mod', { save: 'Speichern' });
|
|
226
|
+
i18n.addResourceBundle('fr', 'other', { save: 'Autre' });
|
|
227
|
+
});
|
|
228
|
+
expect(result.current).toBe(initial);
|
|
229
|
+
|
|
230
|
+
act(() => i18n.addResourceBundle('en', 'mod', { cancel: 'Cancel' }, true));
|
|
231
|
+
expect(result.current.map((entry) => entry.key)).toEqual(['cancel', 'save']);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('points a base key at the plural forms of the selected language', async () => {
|
|
235
|
+
await setUpI18next('ar', {
|
|
236
|
+
en: { mod: { items: 'Items', items_one: '{{count}} item', items_other: '{{count}} items' } },
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const { result } = renderHook(() => useTranslationEntries(['mod'], 'ar'));
|
|
240
|
+
|
|
241
|
+
expect(result.current.find((entry) => entry.key === 'items')?.pluralForms).toEqual([
|
|
242
|
+
'items_zero',
|
|
243
|
+
'items_one',
|
|
244
|
+
'items_two',
|
|
245
|
+
'items_few',
|
|
246
|
+
'items_many',
|
|
247
|
+
'items_other',
|
|
248
|
+
]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('reads a key named like a built-in object property from the bundle that has it', async () => {
|
|
252
|
+
await setUpI18next('fr', {
|
|
253
|
+
fr: { mod: { save: 'Enregistrer' } },
|
|
254
|
+
en: { mod: { save: 'Save', toString: 'Convert to text' } },
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const { result } = renderHook(() => useTranslationEntries(['mod'], 'fr'));
|
|
258
|
+
const entry = result.current.find((candidate) => candidate.key === 'toString');
|
|
259
|
+
|
|
260
|
+
expect(entry?.value).toBe('Convert to text');
|
|
261
|
+
expect(entry?.sourceLanguage).toBe('en');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('attributes a value shown from a fallback language to the override that supplies it', async () => {
|
|
265
|
+
// The backend merges the en override into the en bundle; fr has no value for the key.
|
|
266
|
+
await setUpI18next('fr', { en: { mod: { greeting: 'Overridden hello' } }, fr: { mod: {} } });
|
|
267
|
+
implementerToolsConfigStore.setState({
|
|
268
|
+
config: {
|
|
269
|
+
mod: {
|
|
270
|
+
'Translation overrides': {
|
|
271
|
+
en: { greeting: { _value: 'Overridden hello', _source: 'temporary config' } },
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const { result } = renderHook(() => useTranslationEntries(['mod'], 'fr'));
|
|
278
|
+
|
|
279
|
+
expect(result.current).toEqual([
|
|
280
|
+
expect.objectContaining({
|
|
281
|
+
key: 'greeting',
|
|
282
|
+
value: 'Overridden hello',
|
|
283
|
+
sourceLanguage: 'en',
|
|
284
|
+
overrideSource: 'temporary config',
|
|
285
|
+
}),
|
|
286
|
+
]);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe('useTranslationLanguages', () => {
|
|
291
|
+
it('offers each server locale as the language i18next runs it as', async () => {
|
|
292
|
+
const i18n = i18next.createInstance();
|
|
293
|
+
await i18n.init({ lng: 'en', fallbackLng: 'en', resources: { en: { mod: {} } } });
|
|
294
|
+
window.i18next = i18n as unknown as typeof window.i18next;
|
|
295
|
+
vi.mocked(useSession).mockReturnValue({
|
|
296
|
+
authenticated: true,
|
|
297
|
+
sessionId: '',
|
|
298
|
+
allowedLocales: ['en', 'en_us', 'sw_KE', 'uz@Latn'],
|
|
299
|
+
} as ReturnType<typeof useSession>);
|
|
300
|
+
|
|
301
|
+
const { result } = renderHook(() => useTranslationLanguages('en'));
|
|
302
|
+
|
|
303
|
+
// `uz@Latn` keeps its modifier: overrides keyed `uz-Latn` would never be read.
|
|
304
|
+
expect(result.current).toEqual(['en', 'en-US', 'sw-KE', 'uz@Latn']);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { useSession } from '@openmrs/esm-framework';
|
|
3
|
+
import {
|
|
4
|
+
extensionNamespaceSeparator,
|
|
5
|
+
implementerToolsConfigStore,
|
|
6
|
+
useStore,
|
|
7
|
+
} from '@openmrs/esm-framework/src/internal';
|
|
8
|
+
import { useFrontendModules } from '../hooks';
|
|
9
|
+
import { clearFailedLoads, findLoadFailure, releaseStalledLoads, withTimeout } from './i18next-loads';
|
|
10
|
+
|
|
11
|
+
/** The namespace holding the framework's own translations. It belongs to no frontend module. */
|
|
12
|
+
export const coreNamespace = 'core';
|
|
13
|
+
|
|
14
|
+
/** The reserved config property through which translations are overridden. */
|
|
15
|
+
export const translationOverridesKey = 'Translation overrides';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The `_source` the config system attributes to values set through the implementer tools. Only those
|
|
19
|
+
* can be reset from here; anything else comes from a config the distribution supplies.
|
|
20
|
+
*/
|
|
21
|
+
export const temporaryConfigSource = 'temporary config';
|
|
22
|
+
|
|
23
|
+
export interface TranslationNamespace {
|
|
24
|
+
name: string;
|
|
25
|
+
/** Whether i18next holds resources for this namespace, in which case its keys are browsable. */
|
|
26
|
+
isLoaded: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TranslationEntry {
|
|
30
|
+
namespace: string;
|
|
31
|
+
key: string;
|
|
32
|
+
/** The value i18next holds for the key, before interpolation. An override, where one applies. */
|
|
33
|
+
value: string;
|
|
34
|
+
/**
|
|
35
|
+
* The name of the config that supplies an override for this key in `sourceLanguage`, if any.
|
|
36
|
+
* `undefined` means the value comes from the namespace's translation file.
|
|
37
|
+
*/
|
|
38
|
+
overrideSource?: string;
|
|
39
|
+
/**
|
|
40
|
+
* The language the value was actually read from. It differs from the selected one when the key is
|
|
41
|
+
* missing from that language's bundle and i18next would fall back, which an implementer needs to
|
|
42
|
+
* see before treating the value as the text they are overriding.
|
|
43
|
+
*/
|
|
44
|
+
sourceLanguage: string;
|
|
45
|
+
/**
|
|
46
|
+
* The plural forms the selected language renders this key through, when the key has any. i18next
|
|
47
|
+
* prefers a suffixed form over the base key, so overriding the base alone leaves these untouched.
|
|
48
|
+
*/
|
|
49
|
+
pluralForms: Array<string>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The plural categories i18next suffixes a key with. Context variants (`_male` and the like) are not
|
|
54
|
+
* guessed at: the suffix is arbitrary application data, so detecting one would report unrelated keys
|
|
55
|
+
* as variants of each other.
|
|
56
|
+
*/
|
|
57
|
+
const pluralSuffixes = ['zero', 'one', 'two', 'few', 'many', 'other'];
|
|
58
|
+
|
|
59
|
+
const pluralFormPattern = new RegExp(`^(.+)_(${pluralSuffixes.join('|')})$`);
|
|
60
|
+
|
|
61
|
+
/** The key a plural form belongs to, such as `items` for `items_few`. */
|
|
62
|
+
function getPluralBase(key: string): string | undefined {
|
|
63
|
+
return pluralFormPattern.exec(key)?.[1];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Lists the namespaces whose translations can be browsed: everything i18next has loaded, plus every
|
|
68
|
+
* installed frontend module, so that a module the user has not visited yet can still be loaded on
|
|
69
|
+
* demand via `loadTranslationNamespace`.
|
|
70
|
+
*/
|
|
71
|
+
export function useTranslationNamespaces(language: string): Array<TranslationNamespace> {
|
|
72
|
+
const frontendModules = useFrontendModules();
|
|
73
|
+
const revision = useI18nextRevision(
|
|
74
|
+
(lng, namespace) => lng === language && !namespace.includes(extensionNamespaceSeparator),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return useMemo(() => {
|
|
78
|
+
const loaded = getLoadedNamespaces(language);
|
|
79
|
+
const names = new Set([coreNamespace, ...frontendModules.map((module) => module.name), ...loaded]);
|
|
80
|
+
|
|
81
|
+
return Array.from(names)
|
|
82
|
+
.sort((a, b) => a.localeCompare(b))
|
|
83
|
+
.map((name) => ({ name, isLoaded: loaded.includes(name) }));
|
|
84
|
+
}, [frontendModules, language, revision]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Builds the browsable list of translations for the given namespaces and language.
|
|
89
|
+
*
|
|
90
|
+
* Keys are collected across i18next's whole language resolution hierarchy rather than from the
|
|
91
|
+
* selected language alone: a key that only exists in the fallback language still renders in the UI,
|
|
92
|
+
* and is still something an implementer needs to be able to override for their own language. For
|
|
93
|
+
* the same reason, every plural form the selected language needs is listed, even one no bundle has.
|
|
94
|
+
*/
|
|
95
|
+
export function useTranslationEntries(namespaces: Array<string>, language: string): Array<TranslationEntry> {
|
|
96
|
+
const { config } = useStore(implementerToolsConfigStore);
|
|
97
|
+
const revision = useI18nextRevision(
|
|
98
|
+
(lng, namespace) => namespaces.includes(namespace) && getResolutionHierarchy(language).includes(lng),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return useMemo(() => {
|
|
102
|
+
const languages = getResolutionHierarchy(language);
|
|
103
|
+
const pluralFormSuffixes = window.i18next?.services?.pluralResolver?.getSuffixes?.(language) ?? [];
|
|
104
|
+
|
|
105
|
+
return namespaces.flatMap((namespace) => {
|
|
106
|
+
const bundles = languages.map((lng) => flattenBundle(window.i18next?.store?.data?.[lng]?.[namespace]));
|
|
107
|
+
const bundleKeys = new Set(bundles.flatMap((bundle) => Object.keys(bundle)));
|
|
108
|
+
const pluralKeys = new Set(Array.from(bundleKeys, getPluralBase).filter((base) => base !== undefined));
|
|
109
|
+
const getPluralForms = (key: string) =>
|
|
110
|
+
pluralKeys.has(key) ? pluralFormSuffixes.map((suffix) => `${key}${suffix}`) : [];
|
|
111
|
+
const keys = Array.from(new Set([...bundleKeys, ...Array.from(pluralKeys).flatMap(getPluralForms)])).sort(
|
|
112
|
+
(a, b) => a.localeCompare(b),
|
|
113
|
+
);
|
|
114
|
+
const overrides = config?.[namespace]?.[translationOverridesKey];
|
|
115
|
+
|
|
116
|
+
return keys.map((key) => {
|
|
117
|
+
const source = bundles.findIndex((bundle) => Object.hasOwn(bundle, key));
|
|
118
|
+
const sourceLanguage = languages[source === -1 ? 0 : source];
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
namespace,
|
|
122
|
+
key,
|
|
123
|
+
value: source === -1 ? '' : bundles[source][key],
|
|
124
|
+
sourceLanguage,
|
|
125
|
+
overrideSource: findOverrideSource(overrides, sourceLanguage, key),
|
|
126
|
+
pluralForms: getPluralForms(key),
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}, [config, namespaces, language, revision]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Loads a namespace's resources for the language being browsed, and for any of its fallback
|
|
135
|
+
* languages not loaded yet, since the table also lists the keys only a fallback language has.
|
|
136
|
+
*
|
|
137
|
+
* `reloadResources` rather than `loadNamespaces` because the latter fetches for i18next's active
|
|
138
|
+
* language, which is not necessarily the one selected here.
|
|
139
|
+
*/
|
|
140
|
+
export function loadTranslationNamespace(namespace: string, language: string): Promise<void> {
|
|
141
|
+
const languages = getResolutionHierarchy(language).filter(
|
|
142
|
+
(candidate) => candidate === language || !window.i18next.hasResourceBundle(candidate, namespace),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
clearFailedLoads(languages, [namespace]);
|
|
146
|
+
|
|
147
|
+
return withTimeout(
|
|
148
|
+
new Promise<void>((resolve, reject) => {
|
|
149
|
+
const done = (error?: unknown) => {
|
|
150
|
+
const failure = findLoadFailure(languages, [namespace], error);
|
|
151
|
+
return failure ? reject(failure) : resolve();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// i18next declares this callback as taking no arguments but calls it with the load error.
|
|
155
|
+
// It expands a language into its fallbacks only when given a single string, so they are listed.
|
|
156
|
+
window.i18next.reloadResources(languages, [namespace], done as () => void);
|
|
157
|
+
}),
|
|
158
|
+
`Loading ${namespace} for ${language} did not finish`,
|
|
159
|
+
).catch((error) => {
|
|
160
|
+
releaseStalledLoads(languages, [namespace]);
|
|
161
|
+
throw error;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The languages an implementer can author overrides for: every locale the server permits, plus
|
|
167
|
+
* anything i18next has already loaded, plus whatever is currently selected.
|
|
168
|
+
*
|
|
169
|
+
* The server's list is the authoritative one, and it is what makes a locale the user is not running
|
|
170
|
+
* in reachable at all. Each locale is listed as the language i18next runs it as, which is the key its
|
|
171
|
+
* `Translation overrides` are read under.
|
|
172
|
+
*/
|
|
173
|
+
export function useTranslationLanguages(selectedLanguage: string): Array<string> {
|
|
174
|
+
const session = useSession();
|
|
175
|
+
const revision = useI18nextRevision();
|
|
176
|
+
|
|
177
|
+
return useMemo(() => {
|
|
178
|
+
// As `setUserLanguage` does, then normalised as i18next normalises the language it runs in.
|
|
179
|
+
const allowed = (session?.allowedLocales ?? []).map((locale) => {
|
|
180
|
+
const language = locale.replaceAll('_', '-');
|
|
181
|
+
return window.i18next?.services?.languageUtils?.formatLanguageCode?.(language) ?? language;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return Array.from(new Set([...allowed, ...getLoadedLanguages(), selectedLanguage])).sort((a, b) =>
|
|
185
|
+
a.localeCompare(b),
|
|
186
|
+
);
|
|
187
|
+
}, [revision, selectedLanguage, session]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The languages i18next currently holds resources for. */
|
|
191
|
+
export function getLoadedLanguages(): Array<string> {
|
|
192
|
+
return Object.keys(window.i18next?.store?.data ?? {});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Fetches, for a language the application is not running in, the namespaces the application has
|
|
197
|
+
* loaded, which is what lets an implementer browse and override a locale other than their own.
|
|
198
|
+
*
|
|
199
|
+
* Not `loadLanguages`: it also requests i18next's default `translation` namespace, which the app
|
|
200
|
+
* shell refuses, so it always reports a failure; and it adds the language to `preload`, so every
|
|
201
|
+
* namespace the application loads afterwards is fetched in that language too.
|
|
202
|
+
*/
|
|
203
|
+
export async function loadTranslationLanguage(language: string): Promise<void> {
|
|
204
|
+
const namespaces = new Set((window.i18next.languages ?? []).flatMap((running) => getLoadedNamespaces(running)));
|
|
205
|
+
|
|
206
|
+
await Promise.all(Array.from(namespaces, (namespace) => loadTranslationNamespace(namespace, language)));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function getLoadedNamespaces(language: string): Array<string> {
|
|
210
|
+
return Object.keys(window.i18next?.store?.data?.[language] ?? {}).filter(
|
|
211
|
+
(namespace) => !namespace.includes(extensionNamespaceSeparator),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The top-level string entries of a resource bundle.
|
|
217
|
+
*
|
|
218
|
+
* Nested entries are left out rather than flattened to `a.b`. An override is written at the literal
|
|
219
|
+
* path it is given, and i18next resolves `a.b` by walking `a` then `b` before ever looking for a
|
|
220
|
+
* flat key of that name, so a nested key could be listed and edited here but never actually
|
|
221
|
+
* overridden — offering an action that silently does nothing is worse than not listing it. O3's
|
|
222
|
+
* translation files are flat, so nothing is hidden in practice.
|
|
223
|
+
*/
|
|
224
|
+
export function flattenBundle(bundle: unknown): Record<string, string> {
|
|
225
|
+
if (!bundle || typeof bundle !== 'object') {
|
|
226
|
+
return {};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return Object.entries(bundle).reduce<Record<string, string>>((result, [key, value]) => {
|
|
230
|
+
if (typeof value === 'string') {
|
|
231
|
+
result[key] = value;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return result;
|
|
235
|
+
}, {});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Reports which config supplies an override for a key, or `undefined` if none does.
|
|
240
|
+
*
|
|
241
|
+
* The implementer tools config is a tree of `_value`/`_source` leaves. The config system builds
|
|
242
|
+
* that tree by recursing through every provided config, so an override always appears as a leaf at
|
|
243
|
+
* `[language][key]`, whether it came from a distribution's config file or from an edit made here.
|
|
244
|
+
*/
|
|
245
|
+
export function findOverrideSource(overrides: unknown, language: string, key: string): string | undefined {
|
|
246
|
+
const languageNode = isRecord(overrides) ? overrides[language] : undefined;
|
|
247
|
+
const leaf = isRecord(languageNode) ? languageNode[key] : undefined;
|
|
248
|
+
const source = isRecord(leaf) && '_value' in leaf ? leaf._source : undefined;
|
|
249
|
+
|
|
250
|
+
return typeof source === 'string' ? source : undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
254
|
+
return Boolean(value) && typeof value === 'object';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The languages i18next would consult for the one being browsed, so a key it would resolve from a
|
|
259
|
+
* fallback is listed with the value it would actually render. `i18next.languages` is not that: it is
|
|
260
|
+
* the chain for whichever language the application is running in, so browsing `es` from a French app
|
|
261
|
+
* would otherwise attribute French values to Spanish.
|
|
262
|
+
*/
|
|
263
|
+
function getResolutionHierarchy(language: string): Array<string> {
|
|
264
|
+
const resolved = window.i18next?.services?.languageUtils?.toResolveHierarchy?.(language) ?? [];
|
|
265
|
+
return Array.from(new Set([language, ...resolved]));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Bumps a counter whenever i18next's resources change, giving the catalog a dependency it can
|
|
270
|
+
* recompute on. i18next's resource store is not a zustand store, so it can't be read with
|
|
271
|
+
* `useStore`.
|
|
272
|
+
*
|
|
273
|
+
* @param isRelevant Limits the bumps to the bundles a caller shows, so that loads elsewhere, such as
|
|
274
|
+
* an extension's namespace or another language, don't make it recompute. Reads the latest one
|
|
275
|
+
* passed, so it need not be memoised.
|
|
276
|
+
*/
|
|
277
|
+
function useI18nextRevision(isRelevant: (language: string, namespace: string) => boolean = () => true): number {
|
|
278
|
+
const [revision, setRevision] = useState(0);
|
|
279
|
+
const isRelevantRef = useRef(isRelevant);
|
|
280
|
+
isRelevantRef.current = isRelevant;
|
|
281
|
+
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
const store = window.i18next?.store;
|
|
284
|
+
if (typeof store?.on !== 'function') {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const bump = (language: string, namespace: string) => {
|
|
289
|
+
if (isRelevantRef.current(language, namespace)) {
|
|
290
|
+
setRevision((current) => current + 1);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
// Only `added`: nothing in this feature removes a bundle, so `removed` would never fire anyway.
|
|
294
|
+
store.on('added', bump);
|
|
295
|
+
|
|
296
|
+
return () => {
|
|
297
|
+
store.off('added', bump);
|
|
298
|
+
};
|
|
299
|
+
}, []);
|
|
300
|
+
|
|
301
|
+
return revision;
|
|
302
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getLocalEdits, toFileSafeName, toOverrideFile, toTransifexLocale } from './translation-exports';
|
|
3
|
+
|
|
4
|
+
describe('getLocalEdits', () => {
|
|
5
|
+
const config = {
|
|
6
|
+
'@openmrs/esm-login-app': {
|
|
7
|
+
'Translation overrides': { fr: { loginButton: 'Se connecter', broken: { nested: 'x' } }, en: { other: 'y' } },
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
it('returns the string overrides for just that namespace and language', () => {
|
|
12
|
+
expect(getLocalEdits(config, '@openmrs/esm-login-app', 'fr')).toEqual({ loginButton: 'Se connecter' });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns nothing where no edits were made', () => {
|
|
16
|
+
expect(getLocalEdits(config, '@openmrs/esm-login-app', 'ar')).toEqual({});
|
|
17
|
+
expect(getLocalEdits(config, '@openmrs/esm-other-app', 'fr')).toEqual({});
|
|
18
|
+
expect(getLocalEdits(undefined, '@openmrs/esm-login-app', 'fr')).toEqual({});
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('toOverrideFile', () => {
|
|
23
|
+
it('nests the edits the way a distribution config takes them', () => {
|
|
24
|
+
expect(toOverrideFile('core', 'fr', { save: 'Enregistrer' })).toEqual({
|
|
25
|
+
core: { 'Translation overrides': { fr: { save: 'Enregistrer' } } },
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('toTransifexLocale', () => {
|
|
31
|
+
it('uses the underscore form the translation files are named with', () => {
|
|
32
|
+
expect(toTransifexLocale('ar-SY')).toBe('ar_SY');
|
|
33
|
+
expect(toTransifexLocale('ar')).toBe('ar');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('toFileSafeName', () => {
|
|
38
|
+
it('drops the scope marker and path separator', () => {
|
|
39
|
+
expect(toFileSafeName('@openmrs/esm-login-app')).toBe('openmrs-esm-login-app');
|
|
40
|
+
expect(toFileSafeName('core')).toBe('core');
|
|
41
|
+
});
|
|
42
|
+
});
|