@openmrs/esm-config 10.0.1-pre.5738 → 10.0.1-pre.5745
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/module-config/module-config.d.ts +3 -1
- package/dist/module-config/module-config.d.ts.map +1 -1
- package/dist/module-config/module-config.js +19 -11
- package/dist/module-config/state.d.ts +0 -13
- package/dist/module-config/state.d.ts.map +1 -1
- package/dist/module-config/state.js +0 -34
- package/package.json +7 -7
- package/src/module-config/module-config.test.ts +63 -12
- package/src/module-config/module-config.ts +31 -23
- package/src/module-config/startup-validation.test.ts +47 -0
- package/src/module-config/state.ts +0 -54
package/.turbo/turbo-build.log
CHANGED
|
@@ -89,7 +89,9 @@ export declare function getConfig<T = Record<string, any>>(moduleName: string):
|
|
|
89
89
|
* @param moduleName The name of the module providing the translation context.
|
|
90
90
|
* @param slotName Optional extension slot name to include slot-specific overrides.
|
|
91
91
|
* @param extensionId Optional extension ID to include extension-specific overrides.
|
|
92
|
-
* @returns A Promise resolving to an array of translation override objects.
|
|
92
|
+
* @returns A Promise resolving to an array of translation override objects. It always settles: a
|
|
93
|
+
* module the config system has not been told about, or an extension that is not mounted,
|
|
94
|
+
* contributes no overrides.
|
|
93
95
|
*
|
|
94
96
|
* @internal
|
|
95
97
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"module-config.d.ts","sourceRoot":"","sources":["../../src/module-config/module-config.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,YAAY,EAAuB,MAAM,UAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"module-config.d.ts","sourceRoot":"","sources":["../../src/module-config/module-config.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,YAAY,EAAuB,MAAM,UAAU,CAAC;AAkWxF;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,QAS1E;AAED;;;;;;;;;GASG;AACH,wBAAgB,8BAA8B,CAAC,UAAU,EAAE,MAAM,QAKhE;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,QAKpD;AAED;;;;;;;;GAQG;AACH,wBAAgB,4BAA4B,CAAC,SAAS,EAAE,MAAM,QAK7D;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,2BAA2B,CAAC,aAAa,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,QAetF;AAED;;;;;;;;GAQG;AACH,wBAAgB,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,SAAa,QAK9D;AAiCD;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAMjF;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,uBAAuB,CACrC,UAAU,EAAE,MAAM,EAClB,QAAQ,CAAC,EAAE,MAAM,EACjB,WAAW,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,CA2BxD;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,UAKvG;AAsnBD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,CAAC,EAAE,MAAM,QAUjD;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,SAYhC"}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Type } from "../types.js";
|
|
3
3
|
import { isArray, isBoolean, isUuid, isNumber, isObject, isString } from "../validators/type-validators.js";
|
|
4
4
|
import { validator } from "../validators/validator.js";
|
|
5
|
-
import { configExtensionStore, configInternalStore, getConfigStore,
|
|
5
|
+
import { configExtensionStore, configInternalStore, getConfigStore, getExtensionConfigFromStore, getExtensionSlotsConfigStore, getExtensionsConfigStore, clearConfigDerivationError, implementerToolsConfigStore, invalidateImplementerToolsConfig, recordConfigDerivationError, setImplementerToolsConfigRecomputer, temporaryConfigStore } from "./state.js";
|
|
6
6
|
/**
|
|
7
7
|
* Deep merge two objects, with right-side values taking precedence.
|
|
8
8
|
* Arrays are replaced entirely (not merged by index), matching the
|
|
@@ -36,6 +36,9 @@ function mergeDeepReplace(left, right) {
|
|
|
36
36
|
* which creates stores.
|
|
37
37
|
*/ // Store unsubscribe functions to allow cleanup (e.g., in tests or hot module reloading)
|
|
38
38
|
const configSubscriptions = [];
|
|
39
|
+
/**
|
|
40
|
+
* Track displayed validation messages so they only get displayed once.
|
|
41
|
+
*/ const displayedValidationMessages = new Set();
|
|
39
42
|
/**
|
|
40
43
|
* Recomputes all configuration derived stores based on current state of input stores.
|
|
41
44
|
* Called whenever any input store (configInternalStore, temporaryConfigStore, configExtensionStore) changes.
|
|
@@ -73,8 +76,9 @@ const configSubscriptions = [];
|
|
|
73
76
|
function setupConfigSubscriptions() {
|
|
74
77
|
// Registered here rather than at module load so that `resetConfigSystem` restores it.
|
|
75
78
|
setImplementerToolsConfigRecomputer(()=>computeImplementerToolsConfig(configInternalStore.getState(), temporaryConfigStore.getState()));
|
|
76
|
-
//
|
|
77
|
-
|
|
79
|
+
// Guarded: a configuration the user saved through the implementer tools must not be able to stop
|
|
80
|
+
// the framework from loading.
|
|
81
|
+
recomputeAllConfigsSafely();
|
|
78
82
|
// Subscribe to all input stores with a single handler
|
|
79
83
|
// This ensures we only recompute once even if multiple stores change simultaneously
|
|
80
84
|
configSubscriptions.push(configInternalStore.subscribe(recomputeAllConfigsSafely), temporaryConfigStore.subscribe(recomputeAllConfigsSafely), configExtensionStore.subscribe(recomputeAllConfigsSafely));
|
|
@@ -398,15 +402,24 @@ function computeExtensionConfigs(configState, extensionState, tempConfigState, s
|
|
|
398
402
|
* @param moduleName The name of the module providing the translation context.
|
|
399
403
|
* @param slotName Optional extension slot name to include slot-specific overrides.
|
|
400
404
|
* @param extensionId Optional extension ID to include extension-specific overrides.
|
|
401
|
-
* @returns A Promise resolving to an array of translation override objects.
|
|
405
|
+
* @returns A Promise resolving to an array of translation override objects. It always settles: a
|
|
406
|
+
* module the config system has not been told about, or an extension that is not mounted,
|
|
407
|
+
* contributes no overrides.
|
|
402
408
|
*
|
|
403
409
|
* @internal
|
|
404
410
|
*/ export function getTranslationOverrides(moduleName, slotName, extensionId) {
|
|
411
|
+
// A module's store only ever loads its overrides once the module is registered with the config
|
|
412
|
+
// system, so waiting on one that is not registered would leave the i18next namespace pending.
|
|
405
413
|
const promises = [
|
|
406
|
-
whenStoreReady(getConfigStore(moduleName), (state)=>Boolean(state.translationOverridesLoaded && state.config), (state)=>state.config['Translation overrides'] ?? {})
|
|
414
|
+
moduleName in configInternalStore.getState().schemas ? whenStoreReady(getConfigStore(moduleName), (state)=>Boolean(state.translationOverridesLoaded && state.config), (state)=>state.config['Translation overrides'] ?? {}) : Promise.resolve({})
|
|
407
415
|
];
|
|
408
416
|
if (slotName && extensionId) {
|
|
409
|
-
|
|
417
|
+
// Read once rather than waited for: an extension's config entry exists only while that extension
|
|
418
|
+
// is registered as mounted, so a wait started in the gap never settles and leaves the i18next
|
|
419
|
+
// namespace pending.
|
|
420
|
+
const extensionConfig = getExtensionConfigFromStore(getExtensionsConfigStore().getState(), slotName, extensionId);
|
|
421
|
+
const overrides = extensionConfig.loaded && extensionConfig.config ? extensionConfig.config['Translation overrides'] ?? {} : {};
|
|
422
|
+
promises.push(Promise.resolve(overrides));
|
|
410
423
|
}
|
|
411
424
|
return Promise.all(promises);
|
|
412
425
|
}
|
|
@@ -920,13 +933,8 @@ function hasObjectSchema(elementsSchema) {
|
|
|
920
933
|
function isOrdinaryObject(value) {
|
|
921
934
|
return typeof value === 'object' && !Array.isArray(value) && value !== null;
|
|
922
935
|
}
|
|
923
|
-
/** Keep track of which validation errors we have displayed. Each one should only be displayed once. */ let displayedValidationMessages = new Set();
|
|
924
936
|
function logError(keyPath, message) {
|
|
925
937
|
const key = `${keyPath}:::${message}`;
|
|
926
|
-
// technically, this should not be possible, but because of how things wind-up transpiled, this isn't impossible
|
|
927
|
-
if (!displayedValidationMessages) {
|
|
928
|
-
displayedValidationMessages = new Set();
|
|
929
|
-
}
|
|
930
938
|
if (!displayedValidationMessages.has(key)) {
|
|
931
939
|
console.error(message);
|
|
932
940
|
displayedValidationMessages.add(key);
|
|
@@ -108,19 +108,6 @@ export interface ExtensionsConfigStore {
|
|
|
108
108
|
* @internal
|
|
109
109
|
*/
|
|
110
110
|
export declare function getExtensionsConfigStore(): StoreApi<ExtensionsConfigStore>;
|
|
111
|
-
/**
|
|
112
|
-
* The read-only part of a store's API.
|
|
113
|
-
* @internal
|
|
114
|
-
*/
|
|
115
|
-
export type ReadableStore<T> = Pick<StoreApi<T>, 'getInitialState' | 'getState' | 'subscribe'>;
|
|
116
|
-
/**
|
|
117
|
-
* A read-only view of one extension instance's config within a slot. Read-only because these
|
|
118
|
-
* configs are derived from the config system's inputs, so a write here would be overwritten by the
|
|
119
|
-
* next recomputation; provide a config or set a temporary one instead.
|
|
120
|
-
*
|
|
121
|
-
* @internal
|
|
122
|
-
*/
|
|
123
|
-
export declare function getExtensionConfig(slotName: string, extensionId: string): ReadableStore<Omit<ConfigStore, 'translationOverridesLoaded'>>;
|
|
124
111
|
/** @internal */
|
|
125
112
|
export declare function getExtensionConfigFromStore(state: ExtensionsConfigStore, slotName: string, extensionId: string): ConfigStore;
|
|
126
113
|
/** @internal */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../src/module-config/state.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../src/module-config/state.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createGlobalStore, getGlobalStore, registerGlobalStore } from "@openmrs/esm-state";
|
|
2
|
-
import { shallowEqual } from "@openmrs/esm-utils";
|
|
3
2
|
import { createStore } from "zustand/vanilla";
|
|
4
3
|
const configInternalStoreInitialValue = {
|
|
5
4
|
providedConfigs: [],
|
|
@@ -74,39 +73,6 @@ function initializeConfigStore() {
|
|
|
74
73
|
configs: {}
|
|
75
74
|
});
|
|
76
75
|
}
|
|
77
|
-
/**
|
|
78
|
-
* A read-only view of one extension instance's config within a slot. Read-only because these
|
|
79
|
-
* configs are derived from the config system's inputs, so a write here would be overwritten by the
|
|
80
|
-
* next recomputation; provide a config or set a temporary one instead.
|
|
81
|
-
*
|
|
82
|
-
* @internal
|
|
83
|
-
*/ export function getExtensionConfig(slotName, extensionId) {
|
|
84
|
-
if (typeof slotName !== 'string' || typeof extensionId !== 'string' || slotName === '__proto__' || extensionId === '__proto__' || slotName === 'constructor' || extensionId === 'constructor' || slotName === 'prototype' || extensionId === 'prototype') {
|
|
85
|
-
throw new Error('Attempted to call `getExtensionConfig()` with invalid argument');
|
|
86
|
-
}
|
|
87
|
-
const extensionConfigStore = getExtensionsConfigStore();
|
|
88
|
-
const selector = (configStore)=>configStore.configs[slotName]?.[extensionId];
|
|
89
|
-
return {
|
|
90
|
-
getInitialState () {
|
|
91
|
-
return selector(extensionConfigStore.getInitialState());
|
|
92
|
-
},
|
|
93
|
-
getState () {
|
|
94
|
-
return selector(extensionConfigStore.getState()) ?? {
|
|
95
|
-
loaded: false,
|
|
96
|
-
config: null
|
|
97
|
-
};
|
|
98
|
-
},
|
|
99
|
-
subscribe (listener) {
|
|
100
|
-
return extensionConfigStore.subscribe((state, prevState)=>{
|
|
101
|
-
const newState = selector(state);
|
|
102
|
-
const oldState = selector(prevState);
|
|
103
|
-
if (!shallowEqual(newState, oldState)) {
|
|
104
|
-
listener(newState, oldState);
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
76
|
/** @internal */ export function getExtensionConfigFromStore(state, slotName, extensionId) {
|
|
111
77
|
const extensionConfig = state.configs[slotName]?.[extensionId];
|
|
112
78
|
return extensionConfig ?? {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openmrs/esm-config",
|
|
3
|
-
"version": "10.0.1-pre.
|
|
3
|
+
"version": "10.0.1-pre.5745",
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
5
|
"description": "A configuration library for the OpenMRS Single-Spa framework.",
|
|
6
6
|
"type": "module",
|
|
@@ -57,15 +57,15 @@
|
|
|
57
57
|
"lodash-es": "^4.17.21"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
|
-
"@openmrs/esm-globals": "^10.0.1-pre.
|
|
61
|
-
"@openmrs/esm-state": "^10.0.1-pre.
|
|
62
|
-
"@openmrs/esm-utils": "^10.0.1-pre.
|
|
60
|
+
"@openmrs/esm-globals": "^10.0.1-pre.5745",
|
|
61
|
+
"@openmrs/esm-state": "^10.0.1-pre.5745",
|
|
62
|
+
"@openmrs/esm-utils": "^10.0.1-pre.5745",
|
|
63
63
|
"single-spa": "6.x"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
|
-
"@openmrs/esm-globals": "10.0.1-pre.
|
|
67
|
-
"@openmrs/esm-state": "10.0.1-pre.
|
|
68
|
-
"@openmrs/esm-utils": "10.0.1-pre.
|
|
66
|
+
"@openmrs/esm-globals": "10.0.1-pre.5745",
|
|
67
|
+
"@openmrs/esm-state": "10.0.1-pre.5745",
|
|
68
|
+
"@openmrs/esm-utils": "10.0.1-pre.5745",
|
|
69
69
|
"@swc/cli": "0.8.1",
|
|
70
70
|
"@swc/core": "1.15.21",
|
|
71
71
|
"@types/lodash-es": "^4.17.12",
|
|
@@ -10,7 +10,8 @@ import {
|
|
|
10
10
|
configExtensionStore,
|
|
11
11
|
configInternalStore,
|
|
12
12
|
getConfigStore,
|
|
13
|
-
|
|
13
|
+
getExtensionConfigFromStore,
|
|
14
|
+
getExtensionsConfigStore,
|
|
14
15
|
implementerToolsConfigStore,
|
|
15
16
|
temporaryConfigStore,
|
|
16
17
|
} from './state';
|
|
@@ -1314,7 +1315,7 @@ describe('extension config', () => {
|
|
|
1314
1315
|
extensionId,
|
|
1315
1316
|
})),
|
|
1316
1317
|
});
|
|
1317
|
-
const bar = (extensionId: string) =>
|
|
1318
|
+
const bar = (extensionId: string) => currentExtensionConfig('barSlot', extensionId).config.bar;
|
|
1318
1319
|
|
|
1319
1320
|
mount(['fooExt#a']);
|
|
1320
1321
|
expect(bar('fooExt#a')).toBe('barry');
|
|
@@ -1343,7 +1344,7 @@ describe('extension config', () => {
|
|
|
1343
1344
|
{ slotModuleName: 'slot-mod', extensionModuleName, slotName: 'barSlot', extensionId: 'fooExt' },
|
|
1344
1345
|
],
|
|
1345
1346
|
});
|
|
1346
|
-
const bar = () =>
|
|
1347
|
+
const bar = () => currentExtensionConfig('barSlot', 'fooExt').config.bar;
|
|
1347
1348
|
|
|
1348
1349
|
mount('ext-mod');
|
|
1349
1350
|
expect(bar()).toBe('barry');
|
|
@@ -1356,7 +1357,7 @@ describe('extension config', () => {
|
|
|
1356
1357
|
const moduleLevelConfig = { 'ext-mod': { bar: 'qux' } };
|
|
1357
1358
|
updateConfigExtensionStore();
|
|
1358
1359
|
Config.provide(moduleLevelConfig);
|
|
1359
|
-
const result =
|
|
1360
|
+
const result = currentExtensionConfig('barSlot', 'fooExt').config;
|
|
1360
1361
|
expect(result).toStrictEqual({
|
|
1361
1362
|
bar: 'qux',
|
|
1362
1363
|
baz: 'bazzy',
|
|
@@ -1379,7 +1380,7 @@ describe('extension config', () => {
|
|
|
1379
1380
|
},
|
|
1380
1381
|
};
|
|
1381
1382
|
Config.provide(configureConfig);
|
|
1382
|
-
const result =
|
|
1383
|
+
const result = currentExtensionConfig('barSlot', 'fooExt#id0').config;
|
|
1383
1384
|
expect(result).toStrictEqual({
|
|
1384
1385
|
bar: 'qux',
|
|
1385
1386
|
baz: 'quiz',
|
|
@@ -1404,7 +1405,7 @@ describe('extension config', () => {
|
|
|
1404
1405
|
},
|
|
1405
1406
|
};
|
|
1406
1407
|
Config.provide(configureConfig);
|
|
1407
|
-
const result =
|
|
1408
|
+
const result = currentExtensionConfig('barSlot', 'fooExt#id6').config;
|
|
1408
1409
|
expect(result).toStrictEqual({
|
|
1409
1410
|
bar: 'qux',
|
|
1410
1411
|
baz: 'quiz',
|
|
@@ -1433,7 +1434,7 @@ describe('extension config', () => {
|
|
|
1433
1434
|
},
|
|
1434
1435
|
};
|
|
1435
1436
|
Config.provide(configureConfig);
|
|
1436
|
-
const result =
|
|
1437
|
+
const result = currentExtensionConfig('barSlot', 'fooExt#id7').config;
|
|
1437
1438
|
expect(result).toStrictEqual({
|
|
1438
1439
|
bar: 'from-other',
|
|
1439
1440
|
baz: 'from-owner',
|
|
@@ -1461,7 +1462,7 @@ describe('extension config', () => {
|
|
|
1461
1462
|
// Composing `add` across modules would resurrect entries another module had removed, so the
|
|
1462
1463
|
// last module to name the slot still supplies it whole.
|
|
1463
1464
|
expect(getExtensionSlotsConfigStore().getState().slots['barSlot'].config.add).toStrictEqual(['fooExt#id8']);
|
|
1464
|
-
expect(
|
|
1465
|
+
expect(currentExtensionConfig('barSlot', 'fooExt#id8').config).toStrictEqual({
|
|
1465
1466
|
bar: 'from-first',
|
|
1466
1467
|
baz: 'from-last',
|
|
1467
1468
|
'Display conditions': { expression: undefined, privileges: [] },
|
|
@@ -1474,12 +1475,12 @@ describe('extension config', () => {
|
|
|
1474
1475
|
temporaryConfigStore.setState({
|
|
1475
1476
|
config: { 'other-mod': { extensionSlots: { barSlot: { configure: { 'fooExt#id9': { baz: 'temporary' } } } } } },
|
|
1476
1477
|
});
|
|
1477
|
-
expect(
|
|
1478
|
+
expect(currentExtensionConfig('barSlot', 'fooExt#id9').config.baz).toBe('temporary');
|
|
1478
1479
|
|
|
1479
1480
|
// The derived slot config is not part of the extension config cache key, so a released override
|
|
1480
1481
|
// is only picked up because that cache also keys on the temporary config it was derived from.
|
|
1481
1482
|
temporaryConfigStore.setState({ config: {} });
|
|
1482
|
-
expect(
|
|
1483
|
+
expect(currentExtensionConfig('barSlot', 'fooExt#id9').config.baz).toBe('bazzy');
|
|
1483
1484
|
});
|
|
1484
1485
|
|
|
1485
1486
|
it('validates the extension configure config, with module config schema', () => {
|
|
@@ -1506,7 +1507,7 @@ describe('extension config', () => {
|
|
|
1506
1507
|
Config.registerModuleLoad('fooExt');
|
|
1507
1508
|
const extensionAtBaseConfig = { fooExt: { qux: 'quxolotl' } };
|
|
1508
1509
|
Config.provide(extensionAtBaseConfig);
|
|
1509
|
-
const result =
|
|
1510
|
+
const result = currentExtensionConfig('barSlot', 'fooExt').config;
|
|
1510
1511
|
expect(result).toStrictEqual({
|
|
1511
1512
|
qux: 'quxolotl',
|
|
1512
1513
|
'Display conditions': { expression: undefined, privileges: [] },
|
|
@@ -1531,7 +1532,7 @@ describe('extension config', () => {
|
|
|
1531
1532
|
},
|
|
1532
1533
|
};
|
|
1533
1534
|
Config.provide(configureConfig);
|
|
1534
|
-
const result =
|
|
1535
|
+
const result = currentExtensionConfig('barSlot', 'fooExt#id2').config;
|
|
1535
1536
|
expect(result).toStrictEqual({
|
|
1536
1537
|
qux: 'quxotic',
|
|
1537
1538
|
'Display conditions': { expression: undefined, privileges: [] },
|
|
@@ -1651,6 +1652,18 @@ describe('promise-based config accessors', () => {
|
|
|
1651
1652
|
return counter;
|
|
1652
1653
|
}
|
|
1653
1654
|
|
|
1655
|
+
/**
|
|
1656
|
+
* Rejects if `promise` has not settled within half a second. i18next never retries a namespace
|
|
1657
|
+
* read, so a translation-override wait that never settles leaves every component under that
|
|
1658
|
+
* namespace suspended for good; this turns that hang into a failure.
|
|
1659
|
+
*/
|
|
1660
|
+
function settled<T>(promise: Promise<T>) {
|
|
1661
|
+
return Promise.race([
|
|
1662
|
+
promise,
|
|
1663
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('never settled')), 500)),
|
|
1664
|
+
]);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1654
1667
|
it('getConfig does not subscribe at all when the config has already loaded', async () => {
|
|
1655
1668
|
Config.defineConfigSchema('leak-module', { foo: { _default: 'qux' } });
|
|
1656
1669
|
Config.registerModuleLoad('leak-module');
|
|
@@ -1692,8 +1705,46 @@ describe('promise-based config accessors', () => {
|
|
|
1692
1705
|
|
|
1693
1706
|
expect(subscriptions.live).toBe(0);
|
|
1694
1707
|
});
|
|
1708
|
+
|
|
1709
|
+
it('getTranslationOverrides settles for an extension that is not mounted', async () => {
|
|
1710
|
+
Config.defineConfigSchema('unmounted-ext-module', { foo: { _default: 'qux' } });
|
|
1711
|
+
Config.registerModuleLoad('unmounted-ext-module');
|
|
1712
|
+
|
|
1713
|
+
const overrides = Config.getTranslationOverrides('unmounted-ext-module', 'absent-slot', 'absent-ext');
|
|
1714
|
+
|
|
1715
|
+
await expect(settled(overrides)).resolves.toStrictEqual([{}, {}]);
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1718
|
+
it('getTranslationOverrides settles for a module the config system has not been told about', async () => {
|
|
1719
|
+
const subscriptions = countSubscriptions('unregistered-module');
|
|
1720
|
+
|
|
1721
|
+
await expect(settled(Config.getTranslationOverrides('unregistered-module'))).resolves.toStrictEqual([{}]);
|
|
1722
|
+
expect(subscriptions.live).toBe(0);
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
it("getTranslationOverrides returns a mounted extension's overrides", async () => {
|
|
1726
|
+
Config.defineConfigSchema('ext-mod', {});
|
|
1727
|
+
Config.registerModuleLoad('ext-mod');
|
|
1728
|
+
Config.provide({
|
|
1729
|
+
'slot-mod': {
|
|
1730
|
+
extensionSlots: {
|
|
1731
|
+
barSlot: { configure: { fooExt: { 'Translation overrides': { fr: { hello: 'Bonjour' } } } } },
|
|
1732
|
+
},
|
|
1733
|
+
},
|
|
1734
|
+
});
|
|
1735
|
+
updateConfigExtensionStore('fooExt');
|
|
1736
|
+
|
|
1737
|
+
await expect(Config.getTranslationOverrides('ext-mod', 'barSlot', 'fooExt')).resolves.toStrictEqual([
|
|
1738
|
+
{},
|
|
1739
|
+
{ fr: { hello: 'Bonjour' } },
|
|
1740
|
+
]);
|
|
1741
|
+
});
|
|
1695
1742
|
});
|
|
1696
1743
|
|
|
1744
|
+
function currentExtensionConfig(slotName: string, extensionId: string) {
|
|
1745
|
+
return getExtensionConfigFromStore(getExtensionsConfigStore().getState(), slotName, extensionId);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1697
1748
|
function updateConfigExtensionStore(extensionId = 'fooExt') {
|
|
1698
1749
|
configExtensionStore.setState({
|
|
1699
1750
|
mountedExtensions: [
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
configExtensionStore,
|
|
17
17
|
configInternalStore,
|
|
18
18
|
getConfigStore,
|
|
19
|
-
|
|
19
|
+
getExtensionConfigFromStore,
|
|
20
20
|
getExtensionSlotsConfigStore,
|
|
21
21
|
getExtensionsConfigStore,
|
|
22
22
|
clearConfigDerivationError,
|
|
@@ -66,6 +66,11 @@ function mergeDeepReplace<T extends object>(left: T, right: Partial<T>): T {
|
|
|
66
66
|
// Store unsubscribe functions to allow cleanup (e.g., in tests or hot module reloading)
|
|
67
67
|
const configSubscriptions: Array<() => void> = [];
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Track displayed validation messages so they only get displayed once.
|
|
71
|
+
*/
|
|
72
|
+
const displayedValidationMessages = new Set<string>();
|
|
73
|
+
|
|
69
74
|
/**
|
|
70
75
|
* Recomputes all configuration derived stores based on current state of input stores.
|
|
71
76
|
* Called whenever any input store (configInternalStore, temporaryConfigStore, configExtensionStore) changes.
|
|
@@ -119,8 +124,9 @@ function setupConfigSubscriptions() {
|
|
|
119
124
|
computeImplementerToolsConfig(configInternalStore.getState(), temporaryConfigStore.getState()),
|
|
120
125
|
);
|
|
121
126
|
|
|
122
|
-
//
|
|
123
|
-
|
|
127
|
+
// Guarded: a configuration the user saved through the implementer tools must not be able to stop
|
|
128
|
+
// the framework from loading.
|
|
129
|
+
recomputeAllConfigsSafely();
|
|
124
130
|
|
|
125
131
|
// Subscribe to all input stores with a single handler
|
|
126
132
|
// This ensures we only recompute once even if multiple stores change simultaneously
|
|
@@ -526,7 +532,9 @@ export function getConfig<T = Record<string, any>>(moduleName: string): Promise<
|
|
|
526
532
|
* @param moduleName The name of the module providing the translation context.
|
|
527
533
|
* @param slotName Optional extension slot name to include slot-specific overrides.
|
|
528
534
|
* @param extensionId Optional extension ID to include extension-specific overrides.
|
|
529
|
-
* @returns A Promise resolving to an array of translation override objects.
|
|
535
|
+
* @returns A Promise resolving to an array of translation override objects. It always settles: a
|
|
536
|
+
* module the config system has not been told about, or an extension that is not mounted,
|
|
537
|
+
* contributes no overrides.
|
|
530
538
|
*
|
|
531
539
|
* @internal
|
|
532
540
|
*/
|
|
@@ -535,22 +543,29 @@ export function getTranslationOverrides(
|
|
|
535
543
|
slotName?: string,
|
|
536
544
|
extensionId?: string,
|
|
537
545
|
): Promise<Array<Record<string, Record<string, string>>>> {
|
|
546
|
+
// A module's store only ever loads its overrides once the module is registered with the config
|
|
547
|
+
// system, so waiting on one that is not registered would leave the i18next namespace pending.
|
|
538
548
|
const promises = [
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
549
|
+
moduleName in configInternalStore.getState().schemas
|
|
550
|
+
? whenStoreReady(
|
|
551
|
+
getConfigStore(moduleName),
|
|
552
|
+
(state) => Boolean(state.translationOverridesLoaded && state.config),
|
|
553
|
+
(state) => (state.config!['Translation overrides'] ?? {}) as Record<string, Record<string, string>>,
|
|
554
|
+
)
|
|
555
|
+
: Promise.resolve({}),
|
|
544
556
|
];
|
|
545
557
|
|
|
546
558
|
if (slotName && extensionId) {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
559
|
+
// Read once rather than waited for: an extension's config entry exists only while that extension
|
|
560
|
+
// is registered as mounted, so a wait started in the gap never settles and leaves the i18next
|
|
561
|
+
// namespace pending.
|
|
562
|
+
const extensionConfig = getExtensionConfigFromStore(getExtensionsConfigStore().getState(), slotName, extensionId);
|
|
563
|
+
const overrides =
|
|
564
|
+
extensionConfig.loaded && extensionConfig.config
|
|
565
|
+
? ((extensionConfig.config['Translation overrides'] ?? {}) as Record<string, Record<string, string>>)
|
|
566
|
+
: {};
|
|
567
|
+
|
|
568
|
+
promises.push(Promise.resolve(overrides));
|
|
554
569
|
}
|
|
555
570
|
|
|
556
571
|
return Promise.all(promises);
|
|
@@ -1192,15 +1207,8 @@ function hasObjectSchema(elementsSchema: unknown): elementsSchema is ConfigSchem
|
|
|
1192
1207
|
function isOrdinaryObject(value) {
|
|
1193
1208
|
return typeof value === 'object' && !Array.isArray(value) && value !== null;
|
|
1194
1209
|
}
|
|
1195
|
-
/** Keep track of which validation errors we have displayed. Each one should only be displayed once. */
|
|
1196
|
-
let displayedValidationMessages = new Set<string>();
|
|
1197
|
-
|
|
1198
1210
|
function logError(keyPath: string, message: string) {
|
|
1199
1211
|
const key = `${keyPath}:::${message}`;
|
|
1200
|
-
// technically, this should not be possible, but because of how things wind-up transpiled, this isn't impossible
|
|
1201
|
-
if (!displayedValidationMessages) {
|
|
1202
|
-
displayedValidationMessages = new Set<string>();
|
|
1203
|
-
}
|
|
1204
1212
|
|
|
1205
1213
|
if (!displayedValidationMessages.has(key)) {
|
|
1206
1214
|
console.error(message);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The config system validates the temporary config restored from local storage while the module is
|
|
2
|
+
// still initializing. This covers that path, which is the only one where `logError` can run before the
|
|
3
|
+
// rest of the module body has been evaluated.
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
const temporaryConfigKey = 'openmrs:temporaryConfig';
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.resetModules();
|
|
10
|
+
localStorage.clear();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
localStorage.clear();
|
|
15
|
+
vi.restoreAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('loading with a temporary config saved by the implementer tools', () => {
|
|
19
|
+
it('reports an invalid extension slot config instead of failing to load', async () => {
|
|
20
|
+
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
21
|
+
localStorage.setItem(
|
|
22
|
+
temporaryConfigKey,
|
|
23
|
+
JSON.stringify({ '@openmrs/esm-some-app': { extensionSlots: { 'a-slot': { notAKey: ['x'] } } } }),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// The failure this covers happened here, at module evaluation: validation ran before the module
|
|
27
|
+
// body reached the declaration `logError` reads, so the whole framework failed to initialize with
|
|
28
|
+
// "can't access lexical declaration 'displayedValidationMessages' before initialization".
|
|
29
|
+
await expect(import('./module-config')).resolves.toBeDefined();
|
|
30
|
+
|
|
31
|
+
expect(error.mock.calls.flat().join(' ')).toContain("contains invalid keys 'notAKey'");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('still loads when the temporary config is malformed in a way validation cannot describe', async () => {
|
|
35
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
36
|
+
localStorage.setItem(
|
|
37
|
+
temporaryConfigKey,
|
|
38
|
+
JSON.stringify({ '@openmrs/esm-some-app': { extensionSlots: { 'a-slot': { add: 'not-an-array' } } } }),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
await expect(import('./module-config')).resolves.toBeDefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('loads normally when there is no temporary config', async () => {
|
|
45
|
+
await expect(import('./module-config')).resolves.toBeDefined();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createGlobalStore, getGlobalStore, registerGlobalStore } from '@openmrs/esm-state';
|
|
2
|
-
import { shallowEqual } from '@openmrs/esm-utils';
|
|
3
2
|
import { type StoreApi } from 'zustand';
|
|
4
3
|
import { createStore } from 'zustand/vanilla';
|
|
5
4
|
import type { Config, ConfigObject, ConfigSchema, ExtensionSlotConfig, ProvidedConfig } from '../types';
|
|
@@ -178,59 +177,6 @@ export function getExtensionsConfigStore() {
|
|
|
178
177
|
});
|
|
179
178
|
}
|
|
180
179
|
|
|
181
|
-
/**
|
|
182
|
-
* The read-only part of a store's API.
|
|
183
|
-
* @internal
|
|
184
|
-
*/
|
|
185
|
-
export type ReadableStore<T> = Pick<StoreApi<T>, 'getInitialState' | 'getState' | 'subscribe'>;
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* A read-only view of one extension instance's config within a slot. Read-only because these
|
|
189
|
-
* configs are derived from the config system's inputs, so a write here would be overwritten by the
|
|
190
|
-
* next recomputation; provide a config or set a temporary one instead.
|
|
191
|
-
*
|
|
192
|
-
* @internal
|
|
193
|
-
*/
|
|
194
|
-
export function getExtensionConfig(
|
|
195
|
-
slotName: string,
|
|
196
|
-
extensionId: string,
|
|
197
|
-
): ReadableStore<Omit<ConfigStore, 'translationOverridesLoaded'>> {
|
|
198
|
-
if (
|
|
199
|
-
typeof slotName !== 'string' ||
|
|
200
|
-
typeof extensionId !== 'string' ||
|
|
201
|
-
slotName === '__proto__' ||
|
|
202
|
-
extensionId === '__proto__' ||
|
|
203
|
-
slotName === 'constructor' ||
|
|
204
|
-
extensionId === 'constructor' ||
|
|
205
|
-
slotName === 'prototype' ||
|
|
206
|
-
extensionId === 'prototype'
|
|
207
|
-
) {
|
|
208
|
-
throw new Error('Attempted to call `getExtensionConfig()` with invalid argument');
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const extensionConfigStore = getExtensionsConfigStore();
|
|
212
|
-
const selector = (configStore: ExtensionsConfigStore) => configStore.configs[slotName]?.[extensionId];
|
|
213
|
-
|
|
214
|
-
return {
|
|
215
|
-
getInitialState() {
|
|
216
|
-
return selector(extensionConfigStore.getInitialState());
|
|
217
|
-
},
|
|
218
|
-
getState() {
|
|
219
|
-
return selector(extensionConfigStore.getState()) ?? { loaded: false, config: null };
|
|
220
|
-
},
|
|
221
|
-
subscribe(listener) {
|
|
222
|
-
return extensionConfigStore.subscribe((state, prevState) => {
|
|
223
|
-
const newState = selector(state);
|
|
224
|
-
const oldState = selector(prevState);
|
|
225
|
-
|
|
226
|
-
if (!shallowEqual(newState, oldState)) {
|
|
227
|
-
listener(newState, oldState);
|
|
228
|
-
}
|
|
229
|
-
});
|
|
230
|
-
},
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
180
|
/** @internal */
|
|
235
181
|
export function getExtensionConfigFromStore(state: ExtensionsConfigStore, slotName: string, extensionId: string) {
|
|
236
182
|
const extensionConfig = state.configs[slotName]?.[extensionId];
|