@openmrs/esm-styleguide 10.0.1-pre.5263 → 10.0.1-pre.5299
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/modals/index.d.ts.map +1 -1
- package/dist/modals/index.js +86 -14
- package/package.json +25 -25
- package/src/modals/index.tsx +111 -18
- package/src/modals/modals.test.ts +360 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
[0] Successfully compiled: 123 files, copied 167 files with swc (
|
|
1
|
+
[0] Successfully compiled: 123 files, copied 167 files with swc (325.68ms)
|
|
2
2
|
[0] swc --strip-leading-paths --copy-files src -d dist && svgo -rf dist --quiet exited with code 0
|
|
3
3
|
[1] tsc --project tsconfig.build.json exited with code 0
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/modals/index.tsx"],"names":[],"mappings":"AAOA,KAAK,SAAS,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAE3C,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/modals/index.tsx"],"names":[],"mappings":"AAOA,KAAK,SAAS,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAE3C,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AA8PD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,cAAc,EAAE,WAAW,GAAG,IAAI,QAO7D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,GAAE,UAAe,EAAE,OAAO,GAAE,MAAM,IAAe,cA0BlG"}
|
package/dist/modals/index.js
CHANGED
|
@@ -38,6 +38,37 @@ let parcelCount = 0;
|
|
|
38
38
|
}
|
|
39
39
|
return parcel;
|
|
40
40
|
}
|
|
41
|
+
function isClosing(instance) {
|
|
42
|
+
return instance.state === 'TO_BE_DELETED' || instance.state === 'DELETED';
|
|
43
|
+
}
|
|
44
|
+
function unmountModalParcel(modalName, parcel) {
|
|
45
|
+
if (!parcel?.unmount) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// A failed unmount rejects `unmountPromise` as well as the promise the call returns. They are
|
|
49
|
+
// separate promises, so leaving either without a handler is an unhandled rejection, but one
|
|
50
|
+
// failure is worth reporting only once.
|
|
51
|
+
parcel.unmountPromise?.catch(()=>{});
|
|
52
|
+
parcel.unmount().catch((err)=>{
|
|
53
|
+
console.error(`The modal '${modalName}' failed to unmount`, err);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The modal the user is looking at: the frontmost one that isn't on its way out. Modals being torn
|
|
58
|
+
* down are skipped rather than counted, since their frames are already gone — counting them would
|
|
59
|
+
* leave the modal underneath hidden behind nothing until they finally left the stack.
|
|
60
|
+
*/ function frontmostModal(modalStack) {
|
|
61
|
+
return modalStack.find((instance)=>!isClosing(instance));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Only the frontmost modal is shown; the ones it was opened over stay in the DOM behind it. Applied
|
|
65
|
+
* both as the stack changes and as a frame is inserted, since a frame inserted after the last
|
|
66
|
+
* change to the stack would otherwise never be told.
|
|
67
|
+
*/ function applyModalVisibility(instance, isFrontmost) {
|
|
68
|
+
if (instance.container) {
|
|
69
|
+
instance.container.style.visibility = isFrontmost ? 'unset' : 'hidden';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
41
72
|
const original = window.getComputedStyle(document.body).overflow;
|
|
42
73
|
function handleModalStateUpdate({ modalStack, modalContainer }) {
|
|
43
74
|
if (!modalContainer) {
|
|
@@ -50,7 +81,10 @@ function handleModalStateUpdate({ modalStack, modalContainer }) {
|
|
|
50
81
|
document.body.style.overflow = 'hidden';
|
|
51
82
|
modalContainer.style.visibility = 'unset';
|
|
52
83
|
}
|
|
53
|
-
|
|
84
|
+
// Stable across the pass below: the only states it changes are `NEW` to `MOUNTING` and
|
|
85
|
+
// `TO_BE_DELETED` to `DELETED`, neither of which moves a modal in or out of the running.
|
|
86
|
+
const frontmost = frontmostModal(modalStack);
|
|
87
|
+
modalStack.forEach((instance)=>{
|
|
54
88
|
switch(instance.state){
|
|
55
89
|
case 'NEW':
|
|
56
90
|
{
|
|
@@ -58,27 +92,62 @@ function handleModalStateUpdate({ modalStack, modalContainer }) {
|
|
|
58
92
|
size: instance.props?.size ?? 'md'
|
|
59
93
|
});
|
|
60
94
|
instance.container = modalFrame;
|
|
95
|
+
instance.state = 'MOUNTING';
|
|
61
96
|
renderModalIntoDOM(modalFrame, instance.modalName, instance.props).then((parcel)=>{
|
|
97
|
+
// Release the parcel if it's been closed while mounting
|
|
98
|
+
if (isClosing(instance)) {
|
|
99
|
+
unmountModalParcel(instance.modalName, parcel);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!parcel) {
|
|
103
|
+
// `renderModalIntoDOM` only returns without one when it had nowhere to render it,
|
|
104
|
+
// which it has already reported.
|
|
105
|
+
closeInstance(instance);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
62
108
|
instance.parcel = parcel;
|
|
63
|
-
instance.state = 'MOUNTED';
|
|
64
109
|
modalContainer.prepend(modalFrame);
|
|
65
|
-
|
|
110
|
+
// The stack decides which modal is frontmost, not the DOM order this prepend just
|
|
111
|
+
// established: another modal opened while this one loaded belongs on top of it.
|
|
112
|
+
applyModalVisibility(instance, frontmostModal(modalStore.getState().modalStack) === instance);
|
|
113
|
+
// The parcel is handed back before it has mounted, so this is where the modal is
|
|
114
|
+
// really on screen.
|
|
115
|
+
parcel.mountPromise.then(()=>{
|
|
116
|
+
if (!isClosing(instance)) {
|
|
117
|
+
instance.state = 'MOUNTED';
|
|
118
|
+
}
|
|
119
|
+
}, (err)=>{
|
|
120
|
+
reportError(err);
|
|
121
|
+
instance.parcel = null;
|
|
122
|
+
closeInstance(instance);
|
|
123
|
+
});
|
|
124
|
+
}, (err)=>{
|
|
125
|
+
// Nothing is chained to this, so without a handler a modal that fails to load is
|
|
126
|
+
// only an unhandled rejection — and its instance holds the overlay open over a
|
|
127
|
+
// page the user can no longer reach.
|
|
128
|
+
reportError(err);
|
|
129
|
+
closeInstance(instance);
|
|
66
130
|
});
|
|
67
131
|
break;
|
|
68
132
|
}
|
|
133
|
+
// A frame is inserted as soon as the parcel exists, which is before it has mounted, so a
|
|
134
|
+
// modal opened over one still mounting has to hide it just the same.
|
|
135
|
+
case 'MOUNTING':
|
|
69
136
|
case 'MOUNTED':
|
|
70
|
-
|
|
71
|
-
instance.container.style.visibility = index ? 'hidden' : 'unset';
|
|
72
|
-
}
|
|
137
|
+
applyModalVisibility(instance, instance === frontmost);
|
|
73
138
|
break;
|
|
74
139
|
case 'TO_BE_DELETED':
|
|
140
|
+
instance.state = 'DELETED';
|
|
75
141
|
instance.onClose();
|
|
76
|
-
instance.parcel
|
|
142
|
+
unmountModalParcel(instance.modalName, instance.parcel);
|
|
77
143
|
instance.container?.remove();
|
|
78
144
|
setTimeout(()=>{
|
|
145
|
+
// Read now rather than closed over: modals opened since this was scheduled are in the
|
|
146
|
+
// store's stack but not in the one this pass saw, and would be dropped with it.
|
|
147
|
+
const current = modalStore.getState();
|
|
79
148
|
modalStore.setState({
|
|
80
|
-
|
|
81
|
-
modalStack: modalStack.filter((x)=>x !== instance)
|
|
149
|
+
...current,
|
|
150
|
+
modalStack: current.modalStack.filter((x)=>x !== instance)
|
|
82
151
|
});
|
|
83
152
|
}, 0);
|
|
84
153
|
break;
|
|
@@ -102,14 +171,17 @@ function openInstance(instance) {
|
|
|
102
171
|
});
|
|
103
172
|
}
|
|
104
173
|
function closeInstance(instance) {
|
|
174
|
+
if (isClosing(instance)) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// Mutated rather than replaced by a copy as an in-flight mount will close over this property
|
|
178
|
+
instance.state = 'TO_BE_DELETED';
|
|
105
179
|
const state = modalStore.getState();
|
|
106
|
-
const modalStack = state.modalStack.map((x)=>x === instance ? {
|
|
107
|
-
...x,
|
|
108
|
-
state: 'TO_BE_DELETED'
|
|
109
|
-
} : x);
|
|
110
180
|
modalStore.setState({
|
|
111
181
|
...state,
|
|
112
|
-
modalStack
|
|
182
|
+
modalStack: [
|
|
183
|
+
...state.modalStack
|
|
184
|
+
]
|
|
113
185
|
});
|
|
114
186
|
}
|
|
115
187
|
function closeHighestInstance() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openmrs/esm-styleguide",
|
|
3
|
-
"version": "10.0.1-pre.
|
|
3
|
+
"version": "10.0.1-pre.5299",
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
5
|
"description": "The styleguide for OpenMRS SPA",
|
|
6
6
|
"module": "dist/internal.js",
|
|
@@ -78,18 +78,18 @@
|
|
|
78
78
|
"react-aria-components": "^1.7.1"
|
|
79
79
|
},
|
|
80
80
|
"peerDependencies": {
|
|
81
|
-
"@openmrs/esm-api": "^10.0.1-pre.
|
|
82
|
-
"@openmrs/esm-config": "^10.0.1-pre.
|
|
83
|
-
"@openmrs/esm-emr-api": "^10.0.1-pre.
|
|
84
|
-
"@openmrs/esm-error-handling": "^10.0.1-pre.
|
|
85
|
-
"@openmrs/esm-extensions": "^10.0.1-pre.
|
|
86
|
-
"@openmrs/esm-globals": "^10.0.1-pre.
|
|
87
|
-
"@openmrs/esm-navigation": "^10.0.1-pre.
|
|
88
|
-
"@openmrs/esm-react-utils": "^10.0.1-pre.
|
|
89
|
-
"@openmrs/esm-routes": "^10.0.1-pre.
|
|
90
|
-
"@openmrs/esm-state": "^10.0.1-pre.
|
|
91
|
-
"@openmrs/esm-translations": "^10.0.1-pre.
|
|
92
|
-
"@openmrs/esm-utils": "^10.0.1-pre.
|
|
81
|
+
"@openmrs/esm-api": "^10.0.1-pre.5299",
|
|
82
|
+
"@openmrs/esm-config": "^10.0.1-pre.5299",
|
|
83
|
+
"@openmrs/esm-emr-api": "^10.0.1-pre.5299",
|
|
84
|
+
"@openmrs/esm-error-handling": "^10.0.1-pre.5299",
|
|
85
|
+
"@openmrs/esm-extensions": "^10.0.1-pre.5299",
|
|
86
|
+
"@openmrs/esm-globals": "^10.0.1-pre.5299",
|
|
87
|
+
"@openmrs/esm-navigation": "^10.0.1-pre.5299",
|
|
88
|
+
"@openmrs/esm-react-utils": "^10.0.1-pre.5299",
|
|
89
|
+
"@openmrs/esm-routes": "^10.0.1-pre.5299",
|
|
90
|
+
"@openmrs/esm-state": "^10.0.1-pre.5299",
|
|
91
|
+
"@openmrs/esm-translations": "^10.0.1-pre.5299",
|
|
92
|
+
"@openmrs/esm-utils": "^10.0.1-pre.5299",
|
|
93
93
|
"dayjs": "1.x",
|
|
94
94
|
"i18next": "25.x",
|
|
95
95
|
"react": "18.x",
|
|
@@ -99,18 +99,18 @@
|
|
|
99
99
|
"swr": "2.x"
|
|
100
100
|
},
|
|
101
101
|
"devDependencies": {
|
|
102
|
-
"@openmrs/esm-api": "10.0.1-pre.
|
|
103
|
-
"@openmrs/esm-config": "10.0.1-pre.
|
|
104
|
-
"@openmrs/esm-emr-api": "10.0.1-pre.
|
|
105
|
-
"@openmrs/esm-error-handling": "10.0.1-pre.
|
|
106
|
-
"@openmrs/esm-extensions": "10.0.1-pre.
|
|
107
|
-
"@openmrs/esm-globals": "10.0.1-pre.
|
|
108
|
-
"@openmrs/esm-navigation": "10.0.1-pre.
|
|
109
|
-
"@openmrs/esm-react-utils": "10.0.1-pre.
|
|
110
|
-
"@openmrs/esm-routes": "10.0.1-pre.
|
|
111
|
-
"@openmrs/esm-state": "10.0.1-pre.
|
|
112
|
-
"@openmrs/esm-translations": "10.0.1-pre.
|
|
113
|
-
"@openmrs/esm-utils": "10.0.1-pre.
|
|
102
|
+
"@openmrs/esm-api": "10.0.1-pre.5299",
|
|
103
|
+
"@openmrs/esm-config": "10.0.1-pre.5299",
|
|
104
|
+
"@openmrs/esm-emr-api": "10.0.1-pre.5299",
|
|
105
|
+
"@openmrs/esm-error-handling": "10.0.1-pre.5299",
|
|
106
|
+
"@openmrs/esm-extensions": "10.0.1-pre.5299",
|
|
107
|
+
"@openmrs/esm-globals": "10.0.1-pre.5299",
|
|
108
|
+
"@openmrs/esm-navigation": "10.0.1-pre.5299",
|
|
109
|
+
"@openmrs/esm-react-utils": "10.0.1-pre.5299",
|
|
110
|
+
"@openmrs/esm-routes": "10.0.1-pre.5299",
|
|
111
|
+
"@openmrs/esm-state": "10.0.1-pre.5299",
|
|
112
|
+
"@openmrs/esm-translations": "10.0.1-pre.5299",
|
|
113
|
+
"@openmrs/esm-utils": "10.0.1-pre.5299",
|
|
114
114
|
"@swc/cli": "0.8.1",
|
|
115
115
|
"@swc/core": "1.15.21",
|
|
116
116
|
"@types/geopattern": "^1.2.9",
|
package/src/modals/index.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { createGlobalStore } from '@openmrs/esm-state';
|
|
|
4
4
|
import { getModalRegistration, renderParcel } from '@openmrs/esm-extensions';
|
|
5
5
|
import { reportError } from '@openmrs/esm-error-handling';
|
|
6
6
|
|
|
7
|
-
type ModalInstanceState = 'NEW' | 'MOUNTED' | 'TO_BE_DELETED';
|
|
7
|
+
type ModalInstanceState = 'NEW' | 'MOUNTING' | 'MOUNTED' | 'TO_BE_DELETED' | 'DELETED';
|
|
8
8
|
type ModalSize = 'xs' | 'sm' | 'md' | 'lg';
|
|
9
9
|
|
|
10
10
|
export interface ModalProps {
|
|
@@ -80,6 +80,44 @@ async function renderModalIntoDOM(
|
|
|
80
80
|
return parcel;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
function isClosing(instance: ModalInstance) {
|
|
84
|
+
return instance.state === 'TO_BE_DELETED' || instance.state === 'DELETED';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function unmountModalParcel(modalName: string, parcel: Parcel | null | undefined) {
|
|
88
|
+
if (!parcel?.unmount) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A failed unmount rejects `unmountPromise` as well as the promise the call returns. They are
|
|
93
|
+
// separate promises, so leaving either without a handler is an unhandled rejection, but one
|
|
94
|
+
// failure is worth reporting only once.
|
|
95
|
+
parcel.unmountPromise?.catch(() => {});
|
|
96
|
+
parcel.unmount().catch((err) => {
|
|
97
|
+
console.error(`The modal '${modalName}' failed to unmount`, err);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The modal the user is looking at: the frontmost one that isn't on its way out. Modals being torn
|
|
103
|
+
* down are skipped rather than counted, since their frames are already gone — counting them would
|
|
104
|
+
* leave the modal underneath hidden behind nothing until they finally left the stack.
|
|
105
|
+
*/
|
|
106
|
+
function frontmostModal(modalStack: Array<ModalInstance>) {
|
|
107
|
+
return modalStack.find((instance) => !isClosing(instance));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Only the frontmost modal is shown; the ones it was opened over stay in the DOM behind it. Applied
|
|
112
|
+
* both as the stack changes and as a frame is inserted, since a frame inserted after the last
|
|
113
|
+
* change to the stack would otherwise never be told.
|
|
114
|
+
*/
|
|
115
|
+
function applyModalVisibility(instance: ModalInstance, isFrontmost: boolean) {
|
|
116
|
+
if (instance.container) {
|
|
117
|
+
instance.container.style.visibility = isFrontmost ? 'unset' : 'hidden';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
83
121
|
const original = window.getComputedStyle(document.body).overflow;
|
|
84
122
|
|
|
85
123
|
function handleModalStateUpdate({ modalStack, modalContainer }: ModalState) {
|
|
@@ -95,34 +133,84 @@ function handleModalStateUpdate({ modalStack, modalContainer }: ModalState) {
|
|
|
95
133
|
modalContainer.style.visibility = 'unset';
|
|
96
134
|
}
|
|
97
135
|
|
|
98
|
-
|
|
136
|
+
// Stable across the pass below: the only states it changes are `NEW` to `MOUNTING` and
|
|
137
|
+
// `TO_BE_DELETED` to `DELETED`, neither of which moves a modal in or out of the running.
|
|
138
|
+
const frontmost = frontmostModal(modalStack);
|
|
139
|
+
|
|
140
|
+
modalStack.forEach((instance) => {
|
|
99
141
|
switch (instance.state) {
|
|
100
142
|
case 'NEW': {
|
|
101
143
|
const modalFrame = createModalFrame({ size: instance.props?.size ?? 'md' });
|
|
102
144
|
instance.container = modalFrame;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
145
|
+
instance.state = 'MOUNTING';
|
|
146
|
+
|
|
147
|
+
renderModalIntoDOM(modalFrame, instance.modalName, instance.props).then(
|
|
148
|
+
(parcel) => {
|
|
149
|
+
// Release the parcel if it's been closed while mounting
|
|
150
|
+
if (isClosing(instance)) {
|
|
151
|
+
unmountModalParcel(instance.modalName, parcel);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!parcel) {
|
|
156
|
+
// `renderModalIntoDOM` only returns without one when it had nowhere to render it,
|
|
157
|
+
// which it has already reported.
|
|
158
|
+
closeInstance(instance);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
instance.parcel = parcel;
|
|
163
|
+
modalContainer.prepend(modalFrame);
|
|
164
|
+
// The stack decides which modal is frontmost, not the DOM order this prepend just
|
|
165
|
+
// established: another modal opened while this one loaded belongs on top of it.
|
|
166
|
+
applyModalVisibility(instance, frontmostModal(modalStore.getState().modalStack) === instance);
|
|
167
|
+
|
|
168
|
+
// The parcel is handed back before it has mounted, so this is where the modal is
|
|
169
|
+
// really on screen.
|
|
170
|
+
parcel.mountPromise.then(
|
|
171
|
+
() => {
|
|
172
|
+
if (!isClosing(instance)) {
|
|
173
|
+
instance.state = 'MOUNTED';
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
(err) => {
|
|
177
|
+
reportError(err);
|
|
178
|
+
instance.parcel = null;
|
|
179
|
+
closeInstance(instance);
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
},
|
|
183
|
+
(err) => {
|
|
184
|
+
// Nothing is chained to this, so without a handler a modal that fails to load is
|
|
185
|
+
// only an unhandled rejection — and its instance holds the overlay open over a
|
|
186
|
+
// page the user can no longer reach.
|
|
187
|
+
reportError(err);
|
|
188
|
+
closeInstance(instance);
|
|
189
|
+
},
|
|
190
|
+
);
|
|
109
191
|
break;
|
|
110
192
|
}
|
|
111
193
|
|
|
194
|
+
// A frame is inserted as soon as the parcel exists, which is before it has mounted, so a
|
|
195
|
+
// modal opened over one still mounting has to hide it just the same.
|
|
196
|
+
case 'MOUNTING':
|
|
112
197
|
case 'MOUNTED':
|
|
113
|
-
|
|
114
|
-
instance.container.style.visibility = index ? 'hidden' : 'unset';
|
|
115
|
-
}
|
|
198
|
+
applyModalVisibility(instance, instance === frontmost);
|
|
116
199
|
break;
|
|
117
200
|
|
|
118
201
|
case 'TO_BE_DELETED':
|
|
202
|
+
instance.state = 'DELETED';
|
|
119
203
|
instance.onClose();
|
|
120
|
-
instance.parcel
|
|
204
|
+
unmountModalParcel(instance.modalName, instance.parcel);
|
|
121
205
|
instance.container?.remove();
|
|
122
206
|
setTimeout(() => {
|
|
207
|
+
// Read now rather than closed over: modals opened since this was scheduled are in the
|
|
208
|
+
// store's stack but not in the one this pass saw, and would be dropped with it.
|
|
209
|
+
const current = modalStore.getState();
|
|
210
|
+
|
|
123
211
|
modalStore.setState({
|
|
124
|
-
|
|
125
|
-
modalStack: modalStack.filter((x) => x !== instance),
|
|
212
|
+
...current,
|
|
213
|
+
modalStack: current.modalStack.filter((x) => x !== instance),
|
|
126
214
|
});
|
|
127
215
|
}, 0);
|
|
128
216
|
break;
|
|
@@ -146,13 +234,18 @@ function openInstance(instance: ModalInstance) {
|
|
|
146
234
|
}
|
|
147
235
|
|
|
148
236
|
function closeInstance(instance: ModalInstance) {
|
|
237
|
+
if (isClosing(instance)) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Mutated rather than replaced by a copy as an in-flight mount will close over this property
|
|
242
|
+
instance.state = 'TO_BE_DELETED';
|
|
243
|
+
|
|
149
244
|
const state = modalStore.getState();
|
|
150
|
-
|
|
151
|
-
(x): ModalInstance => (x === instance ? { ...x, state: 'TO_BE_DELETED' } : x),
|
|
152
|
-
);
|
|
245
|
+
|
|
153
246
|
modalStore.setState({
|
|
154
247
|
...state,
|
|
155
|
-
modalStack,
|
|
248
|
+
modalStack: [...state.modalStack],
|
|
156
249
|
});
|
|
157
250
|
}
|
|
158
251
|
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { type Parcel } from 'single-spa';
|
|
3
|
+
|
|
4
|
+
vi.mock('@openmrs/esm-extensions', () => ({
|
|
5
|
+
getModalRegistration: vi.fn(),
|
|
6
|
+
renderParcel: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock('@openmrs/esm-error-handling', () => ({
|
|
10
|
+
reportError: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import { reportError } from '@openmrs/esm-error-handling';
|
|
14
|
+
import { getModalRegistration, renderParcel } from '@openmrs/esm-extensions';
|
|
15
|
+
import { setupModals, showModal } from './index';
|
|
16
|
+
|
|
17
|
+
const lifecycle = {
|
|
18
|
+
bootstrap: () => Promise.resolve(),
|
|
19
|
+
mount: () => Promise.resolve(),
|
|
20
|
+
unmount: () => Promise.resolve(),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function deferred() {
|
|
24
|
+
let reject!: (err: unknown) => void;
|
|
25
|
+
let resolve!: () => void;
|
|
26
|
+
const promise = new Promise<void>((res, rej) => {
|
|
27
|
+
resolve = res;
|
|
28
|
+
reject = rej;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return { promise, resolve, reject };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A stand-in for a single-spa parcel, faithful in the two respects the modal code depends on:
|
|
36
|
+
* `mountPromise` settles after the handle is already in hand, and one failed `unmount()` rejects
|
|
37
|
+
* both the promise it returns and the separate `unmountPromise`.
|
|
38
|
+
*/
|
|
39
|
+
function fakeParcel({ mountPromise = Promise.resolve(), unmountError }: FakeParcelOptions = {}) {
|
|
40
|
+
const unmounted = deferred();
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
mountPromise,
|
|
44
|
+
unmountPromise: unmounted.promise,
|
|
45
|
+
unmount: vi.fn(() => {
|
|
46
|
+
if (unmountError) {
|
|
47
|
+
unmounted.reject(unmountError);
|
|
48
|
+
return Promise.reject(unmountError);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
unmounted.resolve();
|
|
52
|
+
return Promise.resolve();
|
|
53
|
+
}),
|
|
54
|
+
} as unknown as Parcel;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface FakeParcelOptions {
|
|
58
|
+
mountPromise?: Promise<void>;
|
|
59
|
+
unmountError?: Error;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Registers `loads` by modal name, falling back to a modal that loads immediately. Naming them
|
|
64
|
+
* apart is what lets one modal be held in flight while another mounts.
|
|
65
|
+
*/
|
|
66
|
+
function registerModals(loads: Record<string, () => Promise<typeof lifecycle>> = {}) {
|
|
67
|
+
vi.mocked(getModalRegistration).mockImplementation(
|
|
68
|
+
(modalName: string) => ({ load: loads[modalName] ?? (() => Promise.resolve(lifecycle)) }) as never,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A load that stays in flight until `release()`, which is the window each race below opens in. */
|
|
73
|
+
function gatedLoad() {
|
|
74
|
+
let release: () => void = () => {};
|
|
75
|
+
const gate = new Promise<void>((resolve) => {
|
|
76
|
+
release = resolve;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
release,
|
|
81
|
+
load: vi.fn(async () => {
|
|
82
|
+
await gate;
|
|
83
|
+
return lifecycle;
|
|
84
|
+
}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Lets the `.then()` chains inside the modal store's subscriber run. */
|
|
89
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
90
|
+
|
|
91
|
+
function setUpModalContainer() {
|
|
92
|
+
const container = document.createElement('div');
|
|
93
|
+
document.body.append(container);
|
|
94
|
+
setupModals(container);
|
|
95
|
+
|
|
96
|
+
return container;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe('modals', () => {
|
|
100
|
+
const openModals: Array<() => void> = [];
|
|
101
|
+
|
|
102
|
+
/** Records the handle so that {@link afterEach} can close whatever a test leaves open. */
|
|
103
|
+
function open(...args: Parameters<typeof showModal>) {
|
|
104
|
+
const close = showModal(...args);
|
|
105
|
+
openModals.push(close);
|
|
106
|
+
|
|
107
|
+
return close;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
beforeEach(() => {
|
|
111
|
+
document.body.innerHTML = '';
|
|
112
|
+
registerModals();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// The modal store is global, so a modal still on the stack belongs to the next test as well —
|
|
116
|
+
// which, among other things, holds the overlay open over it.
|
|
117
|
+
afterEach(async () => {
|
|
118
|
+
openModals.splice(0).forEach((close) => close());
|
|
119
|
+
await flush();
|
|
120
|
+
await flush();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/*
|
|
124
|
+
* `unmountThisParcel()` rejects for any status but `MOUNTED` — a modal closed while it is still
|
|
125
|
+
* mounting, or one single-spa has hard-failed — and nothing is chained to this call, so without a
|
|
126
|
+
* handler the failure is only ever an unhandled rejection.
|
|
127
|
+
*/
|
|
128
|
+
it('logs a modal that fails to unmount', async () => {
|
|
129
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
130
|
+
const unmountError = new Error('parcel not mounted');
|
|
131
|
+
vi.mocked(renderParcel).mockResolvedValue(fakeParcel({ unmountError }));
|
|
132
|
+
|
|
133
|
+
const container = setUpModalContainer();
|
|
134
|
+
|
|
135
|
+
const close = open('a-modal');
|
|
136
|
+
await flush();
|
|
137
|
+
|
|
138
|
+
close();
|
|
139
|
+
await flush();
|
|
140
|
+
|
|
141
|
+
expect(consoleError).toHaveBeenCalledWith("The modal 'a-modal' failed to unmount", unmountError);
|
|
142
|
+
consoleError.mockRestore();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('tears the modal down even though unmounting failed', async () => {
|
|
146
|
+
vi.mocked(renderParcel).mockResolvedValue(fakeParcel({ unmountError: new Error('parcel not mounted') }));
|
|
147
|
+
|
|
148
|
+
const container = setUpModalContainer();
|
|
149
|
+
|
|
150
|
+
const close = open('another-modal');
|
|
151
|
+
await flush();
|
|
152
|
+
|
|
153
|
+
expect(container.childElementCount).toBe(1);
|
|
154
|
+
|
|
155
|
+
close();
|
|
156
|
+
await flush();
|
|
157
|
+
|
|
158
|
+
expect(container.childElementCount).toBe(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('does not mount a second copy when another modal opens while the first is still loading', async () => {
|
|
162
|
+
const { release, load } = gatedLoad();
|
|
163
|
+
registerModals({ 'slow-modal': load });
|
|
164
|
+
vi.mocked(renderParcel).mockResolvedValue(fakeParcel());
|
|
165
|
+
|
|
166
|
+
const container = setUpModalContainer();
|
|
167
|
+
|
|
168
|
+
open('slow-modal');
|
|
169
|
+
await flush();
|
|
170
|
+
|
|
171
|
+
open('quick-modal');
|
|
172
|
+
await flush();
|
|
173
|
+
|
|
174
|
+
release();
|
|
175
|
+
await flush();
|
|
176
|
+
|
|
177
|
+
expect(load).toHaveBeenCalledTimes(1);
|
|
178
|
+
expect(vi.mocked(renderParcel)).toHaveBeenCalledTimes(2);
|
|
179
|
+
expect(container.childElementCount).toBe(2);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('hides a modal that finishes loading after another has opened on top of it', async () => {
|
|
183
|
+
const { release, load } = gatedLoad();
|
|
184
|
+
registerModals({ 'slow-modal': load });
|
|
185
|
+
vi.mocked(renderParcel).mockResolvedValue(fakeParcel());
|
|
186
|
+
|
|
187
|
+
const container = setUpModalContainer();
|
|
188
|
+
|
|
189
|
+
open('slow-modal');
|
|
190
|
+
await flush();
|
|
191
|
+
|
|
192
|
+
open('quick-modal');
|
|
193
|
+
await flush();
|
|
194
|
+
|
|
195
|
+
release();
|
|
196
|
+
await flush();
|
|
197
|
+
|
|
198
|
+
// Each frame is prepended as it mounts, so the slow one — which mounted last — is first.
|
|
199
|
+
const [slowFrame, quickFrame] = Array.from(container.children);
|
|
200
|
+
expect(slowFrame).toHaveStyle({ visibility: 'hidden' });
|
|
201
|
+
expect(quickFrame).not.toHaveStyle({ visibility: 'hidden' });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('hides a modal already on screen when another opens before its parcel has mounted', async () => {
|
|
205
|
+
const mounting = deferred();
|
|
206
|
+
vi.mocked(renderParcel)
|
|
207
|
+
.mockResolvedValueOnce(fakeParcel({ mountPromise: mounting.promise }))
|
|
208
|
+
.mockResolvedValueOnce(fakeParcel());
|
|
209
|
+
|
|
210
|
+
const container = setUpModalContainer();
|
|
211
|
+
|
|
212
|
+
open('first-modal');
|
|
213
|
+
await flush();
|
|
214
|
+
|
|
215
|
+
// On screen, but its parcel has not finished mounting.
|
|
216
|
+
expect(container.childElementCount).toBe(1);
|
|
217
|
+
|
|
218
|
+
open('second-modal');
|
|
219
|
+
await flush();
|
|
220
|
+
|
|
221
|
+
const [secondFrame, firstFrame] = Array.from(container.children);
|
|
222
|
+
expect(firstFrame).toHaveStyle({ visibility: 'hidden' });
|
|
223
|
+
expect(secondFrame).not.toHaveStyle({ visibility: 'hidden' });
|
|
224
|
+
|
|
225
|
+
mounting.resolve();
|
|
226
|
+
await flush();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('shows the modal underneath the moment the one over it is closed', async () => {
|
|
230
|
+
vi.mocked(renderParcel).mockResolvedValue(fakeParcel());
|
|
231
|
+
|
|
232
|
+
const container = setUpModalContainer();
|
|
233
|
+
|
|
234
|
+
open('under-modal');
|
|
235
|
+
await flush();
|
|
236
|
+
|
|
237
|
+
const closeOver = open('over-modal');
|
|
238
|
+
await flush();
|
|
239
|
+
|
|
240
|
+
const [overFrame, underFrame] = Array.from(container.children);
|
|
241
|
+
expect(overFrame).not.toHaveStyle({ visibility: 'hidden' });
|
|
242
|
+
expect(underFrame).toHaveStyle({ visibility: 'hidden' });
|
|
243
|
+
|
|
244
|
+
// Asserted without flushing: the closed modal stays on the stack until its removal timeout,
|
|
245
|
+
// and the one underneath must not wait on that to come back.
|
|
246
|
+
closeOver();
|
|
247
|
+
|
|
248
|
+
expect(underFrame).not.toHaveStyle({ visibility: 'hidden' });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('unmounts a modal closed while it was still loading, and never shows it', async () => {
|
|
252
|
+
const { release, load } = gatedLoad();
|
|
253
|
+
const parcel = fakeParcel();
|
|
254
|
+
registerModals({ 'slow-modal': load });
|
|
255
|
+
vi.mocked(renderParcel).mockResolvedValue(parcel);
|
|
256
|
+
|
|
257
|
+
const container = setUpModalContainer();
|
|
258
|
+
|
|
259
|
+
const close = open('slow-modal');
|
|
260
|
+
await flush();
|
|
261
|
+
|
|
262
|
+
close();
|
|
263
|
+
await flush();
|
|
264
|
+
|
|
265
|
+
release();
|
|
266
|
+
await flush();
|
|
267
|
+
|
|
268
|
+
expect(container.childElementCount).toBe(0);
|
|
269
|
+
expect(parcel.unmount).toHaveBeenCalledTimes(1);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('reports a modal whose parcel fails to mount and releases the overlay', async () => {
|
|
273
|
+
const mountError = new Error('mount blew up');
|
|
274
|
+
vi.mocked(renderParcel).mockResolvedValue(fakeParcel({ mountPromise: Promise.reject(mountError) }));
|
|
275
|
+
|
|
276
|
+
const container = setUpModalContainer();
|
|
277
|
+
|
|
278
|
+
const onClose = vi.fn();
|
|
279
|
+
open('unmountable-modal', {}, onClose);
|
|
280
|
+
await flush();
|
|
281
|
+
await flush();
|
|
282
|
+
|
|
283
|
+
expect(reportError).toHaveBeenCalledWith(mountError);
|
|
284
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
285
|
+
expect(container.childElementCount).toBe(0);
|
|
286
|
+
expect(document.body).not.toHaveStyle({ overflow: 'hidden' });
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('does not try to unmount a parcel that never mounted', async () => {
|
|
290
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
291
|
+
const parcel = fakeParcel({ mountPromise: Promise.reject(new Error('mount blew up')) });
|
|
292
|
+
vi.mocked(renderParcel).mockResolvedValue(parcel);
|
|
293
|
+
|
|
294
|
+
setUpModalContainer();
|
|
295
|
+
|
|
296
|
+
const close = open('unmountable-modal');
|
|
297
|
+
await flush();
|
|
298
|
+
await flush();
|
|
299
|
+
|
|
300
|
+
// The mount failure has already closed it, so this finds nothing left to do. Were the modal
|
|
301
|
+
// still on the stack, this is the close that would unmount a parcel single-spa marked broken.
|
|
302
|
+
close();
|
|
303
|
+
await flush();
|
|
304
|
+
|
|
305
|
+
expect(parcel.unmount).not.toHaveBeenCalled();
|
|
306
|
+
expect(consoleError).not.toHaveBeenCalled();
|
|
307
|
+
consoleError.mockRestore();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('reports one failed unmount once, over both of the promises it rejects', async () => {
|
|
311
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
312
|
+
vi.mocked(renderParcel).mockResolvedValue(fakeParcel({ unmountError: new Error('parcel not mounted') }));
|
|
313
|
+
|
|
314
|
+
setUpModalContainer();
|
|
315
|
+
|
|
316
|
+
const close = open('a-modal');
|
|
317
|
+
await flush();
|
|
318
|
+
|
|
319
|
+
close();
|
|
320
|
+
await flush();
|
|
321
|
+
|
|
322
|
+
expect(consoleError).toHaveBeenCalledTimes(1);
|
|
323
|
+
consoleError.mockRestore();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('tears a modal down once even if another opens before it leaves the stack', async () => {
|
|
327
|
+
vi.mocked(renderParcel).mockResolvedValue(fakeParcel());
|
|
328
|
+
|
|
329
|
+
setUpModalContainer();
|
|
330
|
+
|
|
331
|
+
const onClose = vi.fn();
|
|
332
|
+
const close = open('a-modal', {}, onClose);
|
|
333
|
+
await flush();
|
|
334
|
+
|
|
335
|
+
// Synchronous, so the second modal lands while the first is still awaiting its removal.
|
|
336
|
+
close();
|
|
337
|
+
open('b-modal');
|
|
338
|
+
await flush();
|
|
339
|
+
|
|
340
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('reports a modal that fails to load and takes it off the stack, closing the overlay', async () => {
|
|
344
|
+
const loadError = new Error('bundle is missing');
|
|
345
|
+
registerModals({ 'broken-modal': () => Promise.reject(loadError) });
|
|
346
|
+
|
|
347
|
+
const container = setUpModalContainer();
|
|
348
|
+
|
|
349
|
+
const onClose = vi.fn();
|
|
350
|
+
open('broken-modal', {}, onClose);
|
|
351
|
+
await flush();
|
|
352
|
+
await flush();
|
|
353
|
+
|
|
354
|
+
expect(reportError).toHaveBeenCalledWith(loadError);
|
|
355
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
356
|
+
expect(container.childElementCount).toBe(0);
|
|
357
|
+
// An instance left on the stack holds the overlay over a page the user can no longer scroll.
|
|
358
|
+
expect(document.body).not.toHaveStyle({ overflow: 'hidden' });
|
|
359
|
+
});
|
|
360
|
+
});
|