@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.
@@ -0,0 +1,53 @@
1
+ import { translationOverridesKey } from './translation-catalog';
2
+
3
+ /**
4
+ * The language Transifex treats as the source for every OpenMRS resource. Its strings come from each
5
+ * app's `en.json` and are pushed from the code, so they are not something to upload changes for.
6
+ */
7
+ export const transifexSourceLanguage = 'en';
8
+
9
+ /** The local (temporary config) overrides for one namespace and language, as a flat key/value map. */
10
+ export function getLocalEdits(
11
+ temporaryConfig: Record<string, any> | undefined,
12
+ namespace: string,
13
+ language: string,
14
+ ): Record<string, string> {
15
+ const edits = temporaryConfig?.[namespace]?.[translationOverridesKey]?.[language] ?? {};
16
+
17
+ return Object.fromEntries(
18
+ Object.entries(edits).filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
19
+ );
20
+ }
21
+
22
+ /** A config file carrying the given edits, in the shape a distribution's config takes them. */
23
+ export function toOverrideFile(namespace: string, language: string, edits: Record<string, string>) {
24
+ return { [namespace]: { [translationOverridesKey]: { [language]: edits } } };
25
+ }
26
+
27
+ /**
28
+ * The locale code Transifex and the translation files use for a language tag: `ar-SY` is stored as
29
+ * `ar_SY.json`.
30
+ */
31
+ export function toTransifexLocale(language: string): string {
32
+ return language.replace(/-/g, '_');
33
+ }
34
+
35
+ /** A file name for a namespace's overrides that is safe on any file system: `openmrs-esm-login-app`. */
36
+ export function toFileSafeName(namespace: string): string {
37
+ return namespace.replace(/^@/, '').replace(/[^\w.-]+/g, '-');
38
+ }
39
+
40
+ /** Saves `data` as a pretty-printed JSON file through the browser's download mechanism. */
41
+ export function downloadJson(fileName: string, data: unknown) {
42
+ const url = URL.createObjectURL(new Blob([JSON.stringify(data, undefined, 2) + '\n'], { type: 'application/json' }));
43
+ const link = document.createElement('a');
44
+
45
+ link.href = url;
46
+ link.download = fileName;
47
+ document.body.appendChild(link);
48
+ link.click();
49
+ link.remove();
50
+
51
+ // Deferred so the download has started before its URL goes away.
52
+ setTimeout(() => URL.revokeObjectURL(url));
53
+ }
@@ -0,0 +1,473 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import i18next, { type BackendModule } from 'i18next';
3
+ import { temporaryConfigStore } from '@openmrs/esm-framework/src/internal';
4
+ import {
5
+ findChangedOverrides,
6
+ reloadTranslations,
7
+ resetTranslationOverride,
8
+ setTranslationOverride,
9
+ watchTranslationOverrides,
10
+ } from './translation-overrides';
11
+
12
+ const loginApp = '@openmrs/esm-login-app';
13
+
14
+ const mockReloadResources = vi.fn((_lngs: unknown, _ns: unknown, done?: (error?: unknown) => void) => done?.());
15
+ const mockHasResourceBundle = vi.fn(() => true);
16
+ const connectorState: Record<string, number> = {};
17
+
18
+ beforeEach(() => {
19
+ for (const key of Object.keys(connectorState)) {
20
+ delete connectorState[key];
21
+ }
22
+
23
+ window.i18next = {
24
+ ...window.i18next,
25
+ language: 'en',
26
+ store: { data: { en: { [loginApp]: {} } } },
27
+ reloadResources: mockReloadResources,
28
+ hasResourceBundle: mockHasResourceBundle,
29
+ getResourceBundle: vi.fn(() => ({})),
30
+ removeResourceBundle: vi.fn(),
31
+ addResourceBundle: vi.fn(),
32
+ options: { ns: [] },
33
+ services: { backendConnector: { state: connectorState } },
34
+ emit: vi.fn(),
35
+ } as unknown as typeof window.i18next;
36
+ });
37
+
38
+ afterEach(() => {
39
+ temporaryConfigStore.setState({ config: {} });
40
+ });
41
+
42
+ describe('setTranslationOverride', () => {
43
+ it('writes the override where the config system expects it', () => {
44
+ setTranslationOverride(loginApp, 'en', 'loginButton', 'Sign in');
45
+
46
+ expect(temporaryConfigStore.getState().config).toEqual({
47
+ [loginApp]: { 'Translation overrides': { en: { loginButton: 'Sign in' } } },
48
+ });
49
+ });
50
+
51
+ it('leaves every other pending config edit alone', () => {
52
+ temporaryConfigStore.setState({
53
+ config: {
54
+ '@openmrs/esm-other-app': { someSetting: 'untouched' },
55
+ [loginApp]: {
56
+ anotherSetting: 'also untouched',
57
+ 'Translation overrides': {
58
+ en: { locationsHeader: 'Facilities' },
59
+ fr: { loginButton: 'Connexion' },
60
+ },
61
+ },
62
+ },
63
+ });
64
+
65
+ setTranslationOverride(loginApp, 'en', 'loginButton', 'Sign in');
66
+
67
+ expect(temporaryConfigStore.getState().config).toEqual({
68
+ '@openmrs/esm-other-app': { someSetting: 'untouched' },
69
+ [loginApp]: {
70
+ anotherSetting: 'also untouched',
71
+ 'Translation overrides': {
72
+ en: { locationsHeader: 'Facilities', loginButton: 'Sign in' },
73
+ fr: { loginButton: 'Connexion' },
74
+ },
75
+ },
76
+ });
77
+ });
78
+
79
+ it('writes a region-qualified language tag under that tag', () => {
80
+ setTranslationOverride(loginApp, 'km-KH', 'loginButton', 'ចូល');
81
+
82
+ expect(temporaryConfigStore.getState().config[loginApp]['Translation overrides']).toEqual({
83
+ 'km-KH': { loginButton: 'ចូល' },
84
+ });
85
+ });
86
+ });
87
+
88
+ describe('resetTranslationOverride', () => {
89
+ it('removes one override and keeps its siblings', () => {
90
+ temporaryConfigStore.setState({
91
+ config: {
92
+ [loginApp]: {
93
+ 'Translation overrides': {
94
+ en: { loginButton: 'Sign in', locationsHeader: 'Facilities' },
95
+ fr: { loginButton: 'Connexion' },
96
+ },
97
+ },
98
+ },
99
+ });
100
+
101
+ resetTranslationOverride(loginApp, 'en', 'loginButton');
102
+
103
+ expect(temporaryConfigStore.getState().config[loginApp]['Translation overrides']).toEqual({
104
+ en: { locationsHeader: 'Facilities' },
105
+ fr: { loginButton: 'Connexion' },
106
+ });
107
+ });
108
+
109
+ it('does nothing for a key that was never overridden', () => {
110
+ temporaryConfigStore.setState({ config: { [loginApp]: { 'Translation overrides': { en: {} } } } });
111
+
112
+ expect(() => resetTranslationOverride(loginApp, 'en', 'loginButton')).not.toThrow();
113
+ expect(temporaryConfigStore.getState().config).toEqual({
114
+ [loginApp]: { 'Translation overrides': { en: {} } },
115
+ });
116
+ });
117
+ });
118
+
119
+ describe('reloadTranslations', () => {
120
+ it('reloads the module and every extension-scoped bundle derived from it', async () => {
121
+ window.i18next.store.data.en = {
122
+ [loginApp]: {},
123
+ [`${loginApp}___some-slot___some-extension`]: {},
124
+ [`${loginApp}___other-slot___other-extension`]: {},
125
+ '@openmrs/esm-other-app___slot___ext': {},
126
+ };
127
+
128
+ await reloadTranslations(loginApp, 'en');
129
+
130
+ expect(mockReloadResources).toHaveBeenCalledWith(
131
+ ['en'],
132
+ [loginApp, `${loginApp}___some-slot___some-extension`, `${loginApp}___other-slot___other-extension`],
133
+ expect.any(Function),
134
+ );
135
+ });
136
+
137
+ it('re-renders the application once the reload lands', async () => {
138
+ await reloadTranslations(loginApp, 'en');
139
+
140
+ expect(window.i18next.emit).toHaveBeenCalledWith('languageChanged', 'en');
141
+ });
142
+
143
+ it('reports a reload i18next skipped, even though the old bundle is still there', async () => {
144
+ // A failed load leaves the previous bundle in place and marks the pair -1, and i18next then
145
+ // calls back as a success, so an absent bundle is not evidence either way.
146
+ mockReloadResources.mockImplementationOnce((_lngs, _ns, done) => {
147
+ connectorState[`en|${loginApp}`] = -1;
148
+ return done?.();
149
+ });
150
+
151
+ await expect(reloadTranslations(loginApp, 'en')).rejects.toThrow(/try again/);
152
+ expect(window.i18next.emit).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it('clears a previous failure so the next attempt actually runs', async () => {
156
+ connectorState[`en|${loginApp}`] = -1;
157
+
158
+ await reloadTranslations(loginApp, 'en');
159
+
160
+ expect(connectorState[`en|${loginApp}`]).toBe(0);
161
+ expect(window.i18next.emit).toHaveBeenCalledWith('languageChanged', 'en');
162
+ });
163
+
164
+ it('drops a reset key from the bundle, which a reload alone would leave behind', async () => {
165
+ // i18next's reload merges over the existing bundle, so a key the fresh payload no longer carries
166
+ // survives — which is what resetting an override produces for a language whose file lacks it.
167
+ const bundle: Record<string, string> = { loginButton: 'Sign in', locationsHeader: 'Locations' };
168
+ window.i18next.getResourceBundle = vi.fn(() => bundle);
169
+
170
+ await reloadTranslations(loginApp, 'en', 'loginButton');
171
+
172
+ expect(bundle).toEqual({ locationsHeader: 'Locations' });
173
+ });
174
+
175
+ it('leaves the bundle alone when no key is being reset', async () => {
176
+ const bundle: Record<string, string> = { loginButton: 'Sign in' };
177
+ window.i18next.getResourceBundle = vi.fn(() => bundle);
178
+
179
+ await reloadTranslations(loginApp, 'en');
180
+
181
+ expect(bundle).toEqual({ loginButton: 'Sign in' });
182
+ });
183
+
184
+ it('reports a reload that quietly did nothing rather than reporting success', async () => {
185
+ // i18next skips a language/namespace pair whose first load failed and calls back as though it
186
+ // had reloaded it, so an absent bundle is the only evidence the reload did nothing.
187
+ mockHasResourceBundle.mockReturnValue(false);
188
+
189
+ await expect(reloadTranslations(loginApp, 'en')).rejects.toThrow(/holds no/);
190
+ expect(window.i18next.emit).not.toHaveBeenCalled();
191
+ });
192
+
193
+ it('reports a failed reload rather than reporting success', async () => {
194
+ mockReloadResources.mockImplementationOnce((_lngs, _ns, done) => {
195
+ connectorState[`en|${loginApp}`] = -1;
196
+ return done?.(new Error('chunk 404'));
197
+ });
198
+
199
+ await expect(reloadTranslations(loginApp, 'en')).rejects.toThrow('chunk 404');
200
+ expect(window.i18next.emit).not.toHaveBeenCalled();
201
+ });
202
+
203
+ it('does not fail over a load error that belongs to another namespace', async () => {
204
+ // i18next hands every waiting request every load error, whichever pair it came from.
205
+ mockReloadResources.mockImplementationOnce((_lngs, _ns, done) => {
206
+ connectorState['en|core'] = -1;
207
+ return done?.([new Error('core failed')]);
208
+ });
209
+
210
+ await expect(reloadTranslations(loginApp, 'en')).resolves.toBeUndefined();
211
+ expect(window.i18next.emit).toHaveBeenCalledWith('languageChanged', 'en');
212
+ });
213
+
214
+ it('reports a failure in an extension-scoped bundle reloaded alongside the module', async () => {
215
+ const extension = `${loginApp}___some-slot___some-extension`;
216
+ window.i18next.store.data.en = { [loginApp]: {}, [extension]: {} };
217
+ mockReloadResources.mockImplementationOnce((_lngs, _ns, done) => {
218
+ connectorState[`en|${extension}`] = -1;
219
+ return done?.();
220
+ });
221
+
222
+ await expect(reloadTranslations(loginApp, 'en')).rejects.toThrow(extension);
223
+ expect(window.i18next.emit).not.toHaveBeenCalled();
224
+ });
225
+
226
+ it('drops a reset key from the extension-scoped bundles too', async () => {
227
+ const extension = `${loginApp}___some-slot___some-extension`;
228
+ const bundles: Record<string, Record<string, string>> = {
229
+ [loginApp]: { loginButton: 'Sign in' },
230
+ [extension]: { loginButton: 'Sign in', other: 'kept' },
231
+ };
232
+ window.i18next.store.data.en = { [loginApp]: {}, [extension]: {} };
233
+ window.i18next.getResourceBundle = vi.fn((_lng: string, ns: string) => bundles[ns]);
234
+
235
+ await reloadTranslations(loginApp, 'en', 'loginButton');
236
+
237
+ expect(bundles).toEqual({ [loginApp]: {}, [extension]: { other: 'kept' } });
238
+ });
239
+
240
+ describe('when a read never finishes', () => {
241
+ beforeEach(() => vi.useFakeTimers());
242
+ afterEach(() => vi.useRealTimers());
243
+
244
+ it('gives up, and lets the next save start a read of its own', async () => {
245
+ mockReloadResources.mockImplementationOnce(() => {
246
+ connectorState[`en|${loginApp}`] = 1;
247
+ });
248
+
249
+ const hung = reloadTranslations(loginApp, 'en');
250
+ const assertion = expect(hung).rejects.toThrow(/did not finish/);
251
+ await vi.advanceTimersByTimeAsync(15_000);
252
+ await assertion;
253
+
254
+ // Left marked as loading, i18next would attach the next request to the read that never ended.
255
+ expect(connectorState[`en|${loginApp}`]).toBeUndefined();
256
+
257
+ const next = reloadTranslations(loginApp, 'en');
258
+ await vi.advanceTimersByTimeAsync(0);
259
+
260
+ await expect(next).resolves.toBeUndefined();
261
+ expect(mockReloadResources).toHaveBeenCalledTimes(2);
262
+ });
263
+
264
+ it('does not reload later for a save that already timed out waiting on an earlier read', async () => {
265
+ const listeners = new Set<() => void>();
266
+ Object.assign(window.i18next.services.backendConnector, {
267
+ on: (_event: string, listener: () => void) => listeners.add(listener),
268
+ off: (_event: string, listener: () => void) => listeners.delete(listener),
269
+ });
270
+ connectorState[`en|${loginApp}`] = 1;
271
+
272
+ const hung = reloadTranslations(loginApp, 'en');
273
+ const assertion = expect(hung).rejects.toThrow(/did not finish/);
274
+ await vi.advanceTimersByTimeAsync(15_000);
275
+ await assertion;
276
+
277
+ // Some other namespace finishing its load.
278
+ listeners.forEach((listener) => listener());
279
+ await vi.advanceTimersByTimeAsync(0);
280
+
281
+ expect(mockReloadResources).not.toHaveBeenCalled();
282
+ expect(listeners.size).toBe(0);
283
+ });
284
+ });
285
+ });
286
+
287
+ describe('findChangedOverrides', () => {
288
+ const overrides = (byLanguage: Record<string, Record<string, string>>) => ({
289
+ [loginApp]: { 'Translation overrides': byLanguage },
290
+ });
291
+
292
+ it('reports the languages whose overrides changed, with the keys that were removed', () => {
293
+ const previous = overrides({ en: { loginButton: 'Sign in', other: 'Kept' }, fr: { loginButton: 'Connexion' } });
294
+ const next = overrides({ en: { other: 'Kept' }, fr: { loginButton: 'Connexion' } });
295
+
296
+ expect(findChangedOverrides(previous, next)).toEqual([
297
+ { namespace: loginApp, language: 'en', droppedKeys: ['loginButton'] },
298
+ ]);
299
+ });
300
+
301
+ it('reports every override when the whole config is cleared', () => {
302
+ expect(findChangedOverrides(overrides({ en: { loginButton: 'Sign in' } }), {})).toEqual([
303
+ { namespace: loginApp, language: 'en', droppedKeys: ['loginButton'] },
304
+ ]);
305
+ });
306
+
307
+ it('reports an added or changed override without dropping anything', () => {
308
+ expect(
309
+ findChangedOverrides(overrides({ en: { loginButton: 'Sign in' } }), overrides({ en: { loginButton: 'Enter' } })),
310
+ ).toEqual([{ namespace: loginApp, language: 'en', droppedKeys: [] }]);
311
+ });
312
+
313
+ it('ignores config changes that leave the overrides alone', () => {
314
+ expect(findChangedOverrides({ [loginApp]: { someSetting: 1 } }, { [loginApp]: { someSetting: 2 } })).toEqual([]);
315
+ });
316
+ });
317
+
318
+ describe('watchTranslationOverrides', () => {
319
+ let stop: () => void;
320
+
321
+ beforeEach(() => {
322
+ stop = watchTranslationOverrides();
323
+ });
324
+
325
+ afterEach(() => {
326
+ stop();
327
+ });
328
+
329
+ it('reloads a loaded namespace whose override is removed from outside the Translations tab', async () => {
330
+ const bundle = { loginButton: 'Sign in' };
331
+ vi.mocked(window.i18next.getResourceBundle).mockReturnValue(bundle);
332
+ temporaryConfigStore.setState({ config: { [loginApp]: { 'Translation overrides': { en: bundle } } } });
333
+ mockReloadResources.mockClear();
334
+
335
+ temporaryConfigStore.setState({ config: {} });
336
+ await vi.waitFor(() => expect(mockReloadResources).toHaveBeenCalledWith(['en'], [loginApp], expect.any(Function)));
337
+
338
+ expect(bundle).toEqual({});
339
+ });
340
+
341
+ it('leaves the reload to the caller of setTranslationOverride and resetTranslationOverride', async () => {
342
+ setTranslationOverride(loginApp, 'en', 'loginButton', 'Sign in');
343
+ resetTranslationOverride(loginApp, 'en', 'loginButton');
344
+ await new Promise((resolve) => setTimeout(resolve, 0));
345
+
346
+ expect(mockReloadResources).not.toHaveBeenCalled();
347
+ });
348
+
349
+ it('does not load a namespace that is not on screen', async () => {
350
+ mockHasResourceBundle.mockReturnValueOnce(false);
351
+ temporaryConfigStore.setState({
352
+ config: { '@openmrs/esm-other-app': { 'Translation overrides': { en: { title: 'Other' } } } },
353
+ });
354
+ await new Promise((resolve) => setTimeout(resolve, 0));
355
+
356
+ expect(mockReloadResources).not.toHaveBeenCalled();
357
+ });
358
+ });
359
+
360
+ describe('reloadTranslations against i18next', () => {
361
+ /** Reads that have started, each settled by calling it. Overrides are collected as a read starts. */
362
+ let pendingReads: Array<(error?: Error) => void>;
363
+ let i18n: ReturnType<typeof i18next.createInstance>;
364
+
365
+ beforeEach(async () => {
366
+ pendingReads = [];
367
+
368
+ const backend: BackendModule = {
369
+ type: 'backend',
370
+ init() {},
371
+ read(language, namespace, callback) {
372
+ // Like the app shell, an extension-scoped namespace takes its module's overrides.
373
+ const moduleName = namespace.split('___')[0];
374
+ const overrides =
375
+ temporaryConfigStore.getState().config?.[moduleName]?.['Translation overrides']?.[language] ?? {};
376
+ pendingReads.push((error) =>
377
+ error ? callback(error, null) : callback(null, { loginButton: 'Log in', ...overrides }),
378
+ );
379
+ },
380
+ };
381
+
382
+ i18n = i18next.createInstance();
383
+ await i18n.use(backend).init({ lng: 'en', fallbackLng: 'en', ns: [], initAsync: false });
384
+ i18n.addResourceBundle('en', loginApp, { loginButton: 'Log in' });
385
+ window.i18next = i18n as unknown as typeof window.i18next;
386
+ });
387
+
388
+ /** Settles every read, including those started in response to an earlier one settling. */
389
+ async function settleReads() {
390
+ let settled: Array<() => void>;
391
+
392
+ do {
393
+ await new Promise((resolve) => setTimeout(resolve, 0));
394
+ settled = pendingReads.splice(0);
395
+ settled.forEach((settle) => settle());
396
+ } while (settled.length > 0);
397
+ }
398
+
399
+ it('applies an override saved while the application is already reading the namespace', async () => {
400
+ i18n.reloadResources(['en'], [loginApp]);
401
+ expect(pendingReads).toHaveLength(1);
402
+
403
+ setTranslationOverride(loginApp, 'en', 'loginButton', 'Sign in');
404
+ const saved = reloadTranslations(loginApp, 'en');
405
+ await settleReads();
406
+
407
+ await expect(saved).resolves.toBeUndefined();
408
+ expect(i18n.getResource('en', loginApp, 'loginButton')).toBe('Sign in');
409
+ });
410
+
411
+ it('applies both of two saves made in quick succession', async () => {
412
+ setTranslationOverride(loginApp, 'en', 'loginButton', 'Sign in');
413
+ const first = reloadTranslations(loginApp, 'en');
414
+ setTranslationOverride(loginApp, 'en', 'locationsHeader', 'Facilities');
415
+ const second = reloadTranslations(loginApp, 'en');
416
+ await settleReads();
417
+
418
+ await Promise.all([first, second]);
419
+ expect(i18n.getResource('en', loginApp, 'locationsHeader')).toBe('Facilities');
420
+ });
421
+
422
+ it('still applies a save made while an earlier read fails', async () => {
423
+ i18n.reloadResources(['en'], [loginApp]);
424
+ setTranslationOverride(loginApp, 'en', 'loginButton', 'Sign in');
425
+ const saved = reloadTranslations(loginApp, 'en');
426
+
427
+ pendingReads.shift()?.(new Error('chunk 404'));
428
+ await settleReads();
429
+
430
+ await expect(saved).resolves.toBeUndefined();
431
+ expect(i18n.getResource('en', loginApp, 'loginButton')).toBe('Sign in');
432
+ });
433
+
434
+ it('applies an override to an extension whose first read is still in flight', async () => {
435
+ const extension = `${loginApp}___some-slot___some-extension`;
436
+ i18n.reloadResources(['en'], [extension]);
437
+
438
+ setTranslationOverride(loginApp, 'en', 'loginButton', 'Sign in');
439
+ const saved = reloadTranslations(loginApp, 'en');
440
+ await settleReads();
441
+
442
+ await expect(saved).resolves.toBeUndefined();
443
+ expect(i18n.getResource('en', extension, 'loginButton')).toBe('Sign in');
444
+ });
445
+
446
+ it('removes a reset key even when an earlier read that still had it lands first', async () => {
447
+ setTranslationOverride(loginApp, 'en', 'extraKey', 'Only an override');
448
+ i18n.reloadResources(['en'], [loginApp]);
449
+
450
+ resetTranslationOverride(loginApp, 'en', 'extraKey');
451
+ const reset = reloadTranslations(loginApp, 'en', 'extraKey');
452
+ await settleReads();
453
+
454
+ await expect(reset).resolves.toBeUndefined();
455
+ expect(i18n.getResource('en', loginApp, 'extraKey')).toBeUndefined();
456
+ });
457
+
458
+ it('restores the translation file when local config is cleared', async () => {
459
+ const stop = watchTranslationOverrides();
460
+ setTranslationOverride(loginApp, 'en', 'loginButton', 'Sign in');
461
+ setTranslationOverride(loginApp, 'en', 'extraKey', 'Only an override');
462
+ const saved = reloadTranslations(loginApp, 'en');
463
+ await settleReads();
464
+ await saved;
465
+
466
+ temporaryConfigStore.setState({ config: {} });
467
+ await settleReads();
468
+ stop();
469
+
470
+ await vi.waitFor(() => expect(i18n.getResource('en', loginApp, 'loginButton')).toBe('Log in'));
471
+ expect(i18n.getResource('en', loginApp, 'extraKey')).toBeUndefined();
472
+ });
473
+ });