Skip to content

Commit 062ebca

Browse files
authored
Merge branch 'main' into fix/implementer-tools-tree-description-crash
2 parents 54a9cda + b2605ec commit 062ebca

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+1138
-587
lines changed

package.json

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,18 @@
2020
"start": "openmrs develop",
2121
"verify": "turbo run lint test typescript",
2222
"postinstall": "husky install",
23-
"test": "cross-env TZ=UTC jest --config jest.config.json --verbose false --passWithNoTests --color",
24-
"test-watch": "cross-env TZ=UTC jest --watch --config jest.config.json --color",
23+
"test": "turbo run test",
2524
"test-e2e": "playwright test",
26-
"coverage": "yarn test --coverage"
25+
"coverage": "turbo run test --coverage"
2726
},
2827
"devDependencies": {
2928
"@playwright/test": "^1.50.1",
3029
"@swc/core": "^1.11.29",
3130
"@swc/jest": "^0.2.38",
32-
"@testing-library/dom": "^9.3.4",
33-
"@testing-library/jest-dom": "^6.1.5",
34-
"@testing-library/react": "^14.1.2",
35-
"@testing-library/user-event": "^14.5.2",
31+
"@testing-library/dom": "^10.4.1",
32+
"@testing-library/jest-dom": "^6.8.0",
33+
"@testing-library/react": "^16.3.0",
34+
"@testing-library/user-event": "^14.6.1",
3635
"@types/jest": "^29.5.12",
3736
"@types/react": "^18.3.21",
3837
"@types/react-dom": "^18.3.0",

packages/apps/esm-implementer-tools-app/jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ module.exports = {
1111
'^lodash-es$': 'lodash',
1212
'^lodash-es/(.*)$': 'lodash/$1',
1313
'\\.(s?css)$': 'identity-obj-proxy',
14+
'@openmrs/esm-framework/src/internal': '@openmrs/esm-framework/mock',
1415
'@openmrs/esm-framework': '@openmrs/esm-framework/mock',
1516
dexie: require.resolve('dexie'),
1617
},

packages/framework/esm-config/src/module-config/module-config.test.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,13 @@ describe('implementer tools config', () => {
10311031
_type: Type.Array,
10321032
_value: [],
10331033
},
1034+
expression: {
1035+
_default: undefined,
1036+
_description: expect.any(String),
1037+
_source: 'default',
1038+
_type: Type.String,
1039+
_value: undefined,
1040+
},
10341041
},
10351042
'Translation overrides': {
10361043
_default: {},
@@ -1261,7 +1268,7 @@ describe('extension config', () => {
12611268
expect(result).toStrictEqual({
12621269
bar: 'qux',
12631270
baz: 'bazzy',
1264-
'Display conditions': { privileges: [] },
1271+
'Display conditions': { expression: undefined, privileges: [] },
12651272
'Translation overrides': {},
12661273
});
12671274
expect(console.error).not.toHaveBeenCalled();
@@ -1284,7 +1291,7 @@ describe('extension config', () => {
12841291
expect(result).toStrictEqual({
12851292
bar: 'qux',
12861293
baz: 'quiz',
1287-
'Display conditions': { privileges: [] },
1294+
'Display conditions': { expression: undefined, privileges: [] },
12881295
'Translation overrides': {},
12891296
});
12901297
expect(console.error).not.toHaveBeenCalled();
@@ -1317,10 +1324,9 @@ describe('extension config', () => {
13171324
const result = getExtensionConfig('barSlot', 'fooExt').getState().config;
13181325
expect(result).toStrictEqual({
13191326
qux: 'quxolotl',
1320-
'Display conditions': { privileges: [] },
1327+
'Display conditions': { expression: undefined, privileges: [] },
13211328
'Translation overrides': {},
13221329
});
1323-
expect(console.error).not.toHaveBeenCalled();
13241330
});
13251331

13261332
it("uses the 'configure' config if one is present, with extension config schema", () => {
@@ -1343,7 +1349,7 @@ describe('extension config', () => {
13431349
const result = getExtensionConfig('barSlot', 'fooExt#id2').getState().config;
13441350
expect(result).toStrictEqual({
13451351
qux: 'quxotic',
1346-
'Display conditions': { privileges: [] },
1352+
'Display conditions': { expression: undefined, privileges: [] },
13471353
'Translation overrides': {},
13481354
});
13491355
});

packages/framework/esm-config/src/module-config/module-config.ts

Lines changed: 81 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
/** @module @category Config */
2-
import { clone, reduce, mergeDeepRight, equals, omit } from 'ramda';
2+
import { clone, equals, reduce, mergeDeepRight, omit } from 'ramda';
33
import type { Config, ConfigObject, ConfigSchema, ExtensionSlotConfig } from '../types';
44
import { Type } from '../types';
55
import { isArray, isBoolean, isUuid, isNumber, isObject, isString } from '../validators/type-validators';
66
import { validator } from '../validators/validator';
77
import { type ConfigExtensionStore, type ConfigInternalStore, type ConfigStore } from './state';
88
import {
9-
configInternalStore,
109
configExtensionStore,
10+
configInternalStore,
1111
getConfigStore,
1212
getExtensionConfig,
13+
getExtensionSlotsConfigStore,
1314
getExtensionsConfigStore,
1415
implementerToolsConfigStore,
1516
temporaryConfigStore,
16-
getExtensionSlotsConfigStore,
1717
} from './state';
1818
import { type TemporaryConfigStore } from '..';
1919

@@ -37,42 +37,37 @@ import { type TemporaryConfigStore } from '..';
3737
* store values at the end. `computeExtensionConfigs` calls `getGlobalStore`,
3838
* which creates stores.
3939
*/
40-
computeModuleConfig(configInternalStore.getState(), temporaryConfigStore.getState());
41-
configInternalStore.subscribe((configState) => computeModuleConfig(configState, temporaryConfigStore.getState()));
42-
temporaryConfigStore.subscribe((tempConfigState) =>
43-
computeModuleConfig(configInternalStore.getState(), tempConfigState),
44-
);
45-
46-
computeImplementerToolsConfig(configInternalStore.getState(), temporaryConfigStore.getState());
47-
configInternalStore.subscribe((configState) =>
48-
computeImplementerToolsConfig(configState, temporaryConfigStore.getState()),
49-
);
50-
temporaryConfigStore.subscribe((tempConfigState) =>
51-
computeImplementerToolsConfig(configInternalStore.getState(), tempConfigState),
52-
);
53-
54-
computeExtensionSlotConfigs(configInternalStore.getState(), temporaryConfigStore.getState());
55-
configInternalStore.subscribe((configState) =>
56-
computeExtensionSlotConfigs(configState, temporaryConfigStore.getState()),
57-
);
58-
temporaryConfigStore.subscribe((tempConfigState) =>
59-
computeExtensionSlotConfigs(configInternalStore.getState(), tempConfigState),
60-
);
61-
62-
computeExtensionConfigs(
63-
configInternalStore.getState(),
64-
configExtensionStore.getState(),
65-
temporaryConfigStore.getState(),
66-
);
67-
configInternalStore.subscribe((configState) => {
68-
computeExtensionConfigs(configState, configExtensionStore.getState(), temporaryConfigStore.getState());
69-
});
70-
configExtensionStore.subscribe((extensionState) => {
71-
computeExtensionConfigs(configInternalStore.getState(), extensionState, temporaryConfigStore.getState());
72-
});
73-
temporaryConfigStore.subscribe((tempConfigState) => {
74-
computeExtensionConfigs(configInternalStore.getState(), configExtensionStore.getState(), tempConfigState);
75-
});
40+
// Store unsubscribe functions to allow cleanup (e.g., in tests or hot module reloading)
41+
const configSubscriptions: Array<() => void> = [];
42+
43+
/**
44+
* Recomputes all configuration derived stores based on current state of input stores.
45+
* Called whenever any input store (configInternalStore, temporaryConfigStore, configExtensionStore) changes.
46+
*/
47+
function recomputeAllConfigs() {
48+
const configState = configInternalStore.getState();
49+
const tempConfigState = temporaryConfigStore.getState();
50+
const extensionState = configExtensionStore.getState();
51+
52+
computeModuleConfig(configState, tempConfigState);
53+
computeImplementerToolsConfig(configState, tempConfigState);
54+
computeExtensionSlotConfigs(configState, tempConfigState);
55+
computeExtensionConfigs(configState, extensionState, tempConfigState);
56+
}
57+
58+
function setupConfigSubscriptions() {
59+
// Initial computation
60+
recomputeAllConfigs();
61+
62+
// Subscribe to all input stores with a single handler
63+
// This ensures we only recompute once even if multiple stores change simultaneously
64+
configSubscriptions.push(configInternalStore.subscribe(recomputeAllConfigs));
65+
configSubscriptions.push(temporaryConfigStore.subscribe(recomputeAllConfigs));
66+
configSubscriptions.push(configExtensionStore.subscribe(recomputeAllConfigs));
67+
}
68+
69+
// Set up subscriptions at module load time
70+
setupConfigSubscriptions();
7671

7772
function computeModuleConfig(state: ConfigInternalStore, tempState: TemporaryConfigStore) {
7873
for (let moduleName of Object.keys(state.schemas)) {
@@ -83,21 +78,24 @@ function computeModuleConfig(state: ConfigInternalStore, tempState: TemporaryCon
8378
// available, which as of this writing blocks the schema definition from occurring
8479
// for modules loaded based on their extensions.
8580
const moduleStore = getConfigStore(moduleName);
81+
let newState;
8682
if (state.moduleLoaded[moduleName]) {
8783
const config = getConfigForModule(moduleName, state, tempState);
88-
moduleStore.setState({
84+
newState = {
8985
translationOverridesLoaded: true,
9086
loaded: true,
9187
config,
92-
});
88+
};
9389
} else {
9490
const config = getConfigForModuleImplicitSchema(moduleName, state, tempState);
95-
moduleStore.setState({
91+
newState = {
9692
translationOverridesLoaded: true,
9793
loaded: false,
9894
config,
99-
});
95+
};
10096
}
97+
98+
moduleStore.setState(newState);
10199
}
102100
}
103101

@@ -109,14 +107,21 @@ function computeExtensionSlotConfigs(state: ConfigInternalStore, tempState: Temp
109107
const slotStore = getExtensionSlotsConfigStore();
110108
const oldState = slotStore.getState();
111109
const newState = { slots: { ...oldState.slots, ...newSlotStoreEntries } };
112-
if (!equals(oldState, newState)) {
110+
111+
if (!equals(oldState.slots, newState.slots)) {
113112
slotStore.setState(newState);
114113
}
115114
}
116115

117116
function computeImplementerToolsConfig(state: ConfigInternalStore, tempConfigState: TemporaryConfigStore) {
117+
const oldState = implementerToolsConfigStore.getState();
118118
const config = getImplementerToolsConfig(state, tempConfigState);
119-
implementerToolsConfigStore.setState({ config });
119+
const newState = { config };
120+
121+
// Use deep equality on the actual config content, not the wrapper object
122+
if (!equals(oldState.config, newState.config)) {
123+
implementerToolsConfigStore.setState(newState);
124+
}
120125
}
121126

122127
function computeExtensionConfigs(
@@ -137,12 +142,19 @@ function computeExtensionConfigs(
137142
tempConfigState,
138143
);
139144

140-
configs[extension.slotName] = {
141-
...configs[extension.slotName],
142-
[extension.extensionId]: { config, loaded: true },
143-
};
145+
if (!configs[extension.slotName]) {
146+
configs[extension.slotName] = {};
147+
}
148+
configs[extension.slotName][extension.extensionId] = { config, loaded: true };
149+
}
150+
const extensionsConfigStore = getExtensionsConfigStore();
151+
const oldState = extensionsConfigStore.getState();
152+
const newState = { configs };
153+
154+
// Use deep equality to only update if configs actually changed
155+
if (!equals(oldState.configs, newState.configs)) {
156+
extensionsConfigStore.setState(newState);
144157
}
145-
getExtensionsConfigStore().setState({ configs });
146158
}
147159

148160
/*
@@ -862,6 +874,20 @@ export function clearConfigErrors(keyPath?: string) {
862874
}
863875
}
864876

877+
/**
878+
* Cleans up all config store subscriptions and re-establishes them. This is primarily
879+
* useful for testing, where subscriptions set up at module load time need to be cleared
880+
* between tests to prevent infinite update loops. After clearing, subscriptions are
881+
* re-established so the config system continues to work normally.
882+
*
883+
* @internal
884+
*/
885+
export function resetConfigSystem() {
886+
configSubscriptions.forEach((unsubscribe) => unsubscribe());
887+
configSubscriptions.length = 0;
888+
setupConfigSubscriptions();
889+
}
890+
865891
/**
866892
* Copied over from esm-extensions. It rightly belongs to that module, but esm-config
867893
* cannot depend on esm-extensions.
@@ -905,6 +931,11 @@ const implicitConfigSchema: ConfigSchema = {
905931
_type: Type.Array,
906932
_default: [],
907933
},
934+
expression: {
935+
_description: 'The expression that determines whether the extension is displayed',
936+
_type: Type.String,
937+
_default: undefined,
938+
},
908939
},
909940
...translationOverridesSchema,
910941
};

packages/framework/esm-config/src/module-config/state.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ function initializeConfigStore() {
111111

112112
/** @internal */
113113
export function getConfigStore(moduleName: string) {
114-
// We use a store for each module's config, named `config-${moduleName}`
114+
// We use a store for each module's config, named `config-module-${moduleName}`
115115
return getGlobalStore<ConfigStore>(`config-module-${moduleName}`, initializeConfigStore());
116116
}
117117

packages/framework/esm-config/src/setup-tests.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
/* eslint-disable no-undef */
2+
import { vi } from 'vitest';
3+
24
global.window.System = {
3-
import: jest.fn().mockRejectedValue(new Error('config.json not available in import map')),
4-
resolve: jest.fn().mockImplementation(() => {
5+
import: vi.fn().mockRejectedValue(new Error('config.json not available in import map')),
6+
resolve: vi.fn().mockImplementation(() => {
57
throw new Error('config.json not available in import map');
68
}),
7-
register: jest.fn(),
9+
register: vi.fn(),
810
};
911

1012
global.window.openmrsBase = '/openmrs';

packages/framework/esm-expression-evaluator/src/evaluator.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,13 @@ export function evaluateAsType<T>(
242242
throw `Unknown expression type ${expression}. Expressions must either be a string or pre-compiled string.`;
243243
}
244244

245+
if (typeof expression === 'string' && expression.trim().length === 0) {
246+
throw {
247+
type: 'Empty expression',
248+
message: 'Expression cannot be an empty string',
249+
};
250+
}
251+
245252
if (typeof variables === 'undefined' || variables === null) {
246253
variables = {};
247254
}
@@ -286,6 +293,13 @@ export async function evaluateAsTypeAsync<T>(
286293
);
287294
}
288295

296+
if (typeof expression === 'string' && expression.trim().length === 0) {
297+
throw {
298+
type: 'Empty expression',
299+
message: 'Expression cannot be an empty string',
300+
};
301+
}
302+
289303
if (typeof variables === 'undefined' || variables === null) {
290304
variables = {};
291305
}

0 commit comments

Comments
 (0)