@openmrs/esm-app-shell 10.0.1-pre.5635 → 10.0.1-pre.5649
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/.tmp/openmrs.css +83 -14
- package/.tmp/{openmrs.0bec35ab6fc6bff6.css → openmrs.f542494f33fc3fd1.css} +1 -1
- package/.turbo/turbo-build.log +4 -4
- package/dist/08548eeb70eab808.js +1 -0
- package/dist/{05ce2d37a1daa834.js.map → 08548eeb70eab808.js.map} +1 -1
- package/dist/82f4c9ff65af3be0.js +1 -0
- package/dist/82f4c9ff65af3be0.js.map +1 -0
- package/dist/{415d3f92b43b446a.js → ec68dad9d5d18a20.js} +6 -6
- package/dist/ec68dad9d5d18a20.js.map +1 -0
- package/dist/index.html +1 -3
- package/dist/{openmrs.7c9a1b50c6b0e314.js → openmrs.0b5d40e54172194f.js} +3 -3
- package/dist/{openmrs.7c9a1b50c6b0e314.js.map → openmrs.0b5d40e54172194f.js.map} +1 -1
- package/dist/{openmrs.0bec35ab6fc6bff6.css → openmrs.f542494f33fc3fd1.css} +1 -1
- package/lib/{0f63a955dacaced7.js → 0a6b767a56d12e92.js} +5 -5
- package/lib/364dacef27c6d708.js +8 -0
- package/lib/{29f55baeca95f68a.js → cfeaaf9344214cc7.js} +21 -2
- package/lib/index.html +29 -27
- package/lib/openmrs.css +83 -14
- package/lib/openmrs.js +6 -6
- package/package.json +3 -3
- package/setup-tests.ts +5 -0
- package/src/index.ejs +28 -26
- package/src/language-detection.ts +5 -0
- package/src/locale.ts +2 -4
- package/src/run.test.ts +164 -14
- package/src/run.ts +73 -7
- package/src/static-page-translations.test.ts +68 -0
- package/src/static-page-translations.ts +53 -0
- package/src/ui/server-starting.component.tsx +74 -0
- package/src/ui/server-starting.test.tsx +45 -0
- package/vitest.config.ts +3 -0
- package/dist/05ce2d37a1daa834.js +0 -1
- package/dist/415d3f92b43b446a.js.map +0 -1
- package/dist/ac0d2d52a052f5ee.js +0 -1
- package/dist/ac0d2d52a052f5ee.js.map +0 -1
- package/lib/28cd0b4d1d48499c.js +0 -8
package/src/run.test.ts
CHANGED
|
@@ -1,30 +1,49 @@
|
|
|
1
|
-
// Boot
|
|
2
|
-
//
|
|
3
|
-
|
|
1
|
+
// Boot stays pending until a test calls `harness.boot()`, and a started boot always succeeds, so the only
|
|
2
|
+
// error page these tests can see is the one raised by a failed session fetch, not the one
|
|
3
|
+
// `handleInitFailure` renders for a failed startup.
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
5
|
|
|
5
6
|
interface SessionState {
|
|
6
7
|
loaded: boolean;
|
|
7
8
|
session: unknown;
|
|
8
9
|
error?: Error;
|
|
10
|
+
initializing?: boolean;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
|
-
const harness = vi.hoisted(() =>
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
const harness = vi.hoisted(() => {
|
|
14
|
+
let releaseBoot: () => void = () => {};
|
|
15
|
+
return {
|
|
16
|
+
refetchCurrentUser: vi.fn(() => Promise.reject()),
|
|
17
|
+
removeServerStartingPage: vi.fn(),
|
|
18
|
+
renderServerStartingPage: vi.fn(),
|
|
19
|
+
listeners: new Set<(state: SessionState) => void>(),
|
|
20
|
+
state: { loaded: false, session: null } as SessionState,
|
|
21
|
+
bootGate: Promise.resolve(),
|
|
22
|
+
reset() {
|
|
23
|
+
this.listeners.clear();
|
|
24
|
+
this.state = { loaded: false, session: null };
|
|
25
|
+
this.bootGate = new Promise<void>((resolve) => (releaseBoot = resolve));
|
|
26
|
+
},
|
|
27
|
+
boot() {
|
|
28
|
+
releaseBoot();
|
|
29
|
+
},
|
|
30
|
+
push(state: SessionState) {
|
|
31
|
+
this.state = state;
|
|
32
|
+
this.listeners.forEach((listener) => listener(state));
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
});
|
|
17
36
|
|
|
18
37
|
vi.mock('@openmrs/esm-framework/src/internal', () => {
|
|
19
38
|
const noop = () => {};
|
|
20
39
|
return {
|
|
21
40
|
finishRegisteringAllApps: noop,
|
|
22
41
|
fireOpenmrsEvent: noop,
|
|
23
|
-
getConfig:
|
|
42
|
+
getConfig: async () => ({ preferredCalendar: {} }),
|
|
24
43
|
getCoreTranslation: noop,
|
|
25
|
-
getCurrentRouteMap:
|
|
44
|
+
getCurrentRouteMap: async () => ({ routes: {} }),
|
|
26
45
|
getSessionStore: () => ({
|
|
27
|
-
getState: () =>
|
|
46
|
+
getState: () => harness.state,
|
|
28
47
|
subscribe: (listener: (state: SessionState) => void) => {
|
|
29
48
|
harness.listeners.add(listener);
|
|
30
49
|
return () => harness.listeners.delete(listener);
|
|
@@ -32,7 +51,9 @@ vi.mock('@openmrs/esm-framework/src/internal', () => {
|
|
|
32
51
|
}),
|
|
33
52
|
integrateBreakpoints: noop,
|
|
34
53
|
interpolateUrl: (url: string) => url,
|
|
54
|
+
makeUrl: (path: string) => `/openmrs${path}`,
|
|
35
55
|
provide: noop,
|
|
56
|
+
refetchCurrentUser: harness.refetchCurrentUser,
|
|
36
57
|
registerApp: noop,
|
|
37
58
|
registerDefaultCalendar: noop,
|
|
38
59
|
renderActionableNotifications: noop,
|
|
@@ -45,7 +66,7 @@ vi.mock('@openmrs/esm-framework/src/internal', () => {
|
|
|
45
66
|
setupHistory: noop,
|
|
46
67
|
setupImportMapOverrides: noop,
|
|
47
68
|
setupModals: noop,
|
|
48
|
-
setupRouteMapOverrides: () =>
|
|
69
|
+
setupRouteMapOverrides: () => harness.bootGate,
|
|
49
70
|
showActionableNotification: noop,
|
|
50
71
|
showNotification: noop,
|
|
51
72
|
showSnackbar: noop,
|
|
@@ -59,20 +80,30 @@ vi.mock('@openmrs/esm-framework/src/internal', () => {
|
|
|
59
80
|
});
|
|
60
81
|
|
|
61
82
|
vi.mock('single-spa', () => ({ start: () => {} }));
|
|
83
|
+
// Node has no `Intl.DurationFormat`, and fake timers can't flush the dynamic import `run()` loads the polyfill with.
|
|
84
|
+
vi.mock('@formatjs/intl-durationformat/lib/polyfill', () => ({}));
|
|
62
85
|
vi.mock('@openmrs/esm-styleguide/src/index', () => ({ setupStyleguide: () => {} }));
|
|
63
86
|
vi.mock('./locale', () => ({ setupI18n: () => {} }));
|
|
87
|
+
vi.mock('./static-page-translations', () => ({ translateStaticPage: () => {} }));
|
|
64
88
|
vi.mock('./routing-events', () => ({}));
|
|
65
89
|
vi.mock('./events', () => ({}));
|
|
66
90
|
vi.mock('./ui', () => ({ appName: '@openmrs/esm-app-shell', getCoreExtensions: () => [] }));
|
|
91
|
+
vi.mock('./ui/server-starting.component', () => ({ renderServerStartingPage: harness.renderServerStartingPage }));
|
|
67
92
|
vi.mock('./core-config', () => ({ setupCoreConfig: () => {} }));
|
|
68
93
|
|
|
69
94
|
import { run } from './run';
|
|
70
95
|
|
|
71
96
|
const errorPage = () => document.querySelector('.omrs-app-error');
|
|
97
|
+
const startingUp: SessionState = {
|
|
98
|
+
loaded: false,
|
|
99
|
+
session: null,
|
|
100
|
+
error: new Error('The server is still starting up'),
|
|
101
|
+
initializing: true,
|
|
102
|
+
};
|
|
72
103
|
|
|
73
104
|
describe('the session error page', () => {
|
|
74
105
|
beforeEach(() => {
|
|
75
|
-
harness.
|
|
106
|
+
harness.reset();
|
|
76
107
|
document.body.innerHTML = `
|
|
77
108
|
<template id="app-error">
|
|
78
109
|
<div class="omrs-app-error"><code data-var="message"></code></div>
|
|
@@ -108,4 +139,123 @@ describe('the session error page', () => {
|
|
|
108
139
|
expect(errorPage()).toBeNull();
|
|
109
140
|
expect(harness.listeners.size).toBe(0);
|
|
110
141
|
});
|
|
142
|
+
|
|
143
|
+
it('copies only the error message and briefly shows that it was copied', () => {
|
|
144
|
+
vi.useFakeTimers();
|
|
145
|
+
const copyText = vi.fn();
|
|
146
|
+
vi.stubGlobal('copyText', copyText);
|
|
147
|
+
document.body.innerHTML = `
|
|
148
|
+
<template id="app-error">
|
|
149
|
+
<div class="omrs-app-error">
|
|
150
|
+
<code data-var="message"></code>
|
|
151
|
+
<span class="cds--popover-container"><button data-copy-btn></button><span>Copied!</span></span>
|
|
152
|
+
</div>
|
|
153
|
+
</template>`;
|
|
154
|
+
harness.push({ loaded: false, session: null, error: new Error('Bad Gateway') });
|
|
155
|
+
|
|
156
|
+
document.querySelector<HTMLButtonElement>('[data-copy-btn]')!.click();
|
|
157
|
+
expect(copyText).toHaveBeenCalledWith(document.querySelector('.omrs-app-error code'));
|
|
158
|
+
expect(document.querySelector('.cds--popover-container')).toHaveClass('cds--popover--open');
|
|
159
|
+
|
|
160
|
+
vi.advanceTimersByTime(2000);
|
|
161
|
+
expect(document.querySelector('.cds--popover-container')).not.toHaveClass('cds--popover--open');
|
|
162
|
+
vi.useRealTimers();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('the server starting page', () => {
|
|
167
|
+
// A successful boot defines non-configurable globals, so they are made configurable here to let each
|
|
168
|
+
// test boot again.
|
|
169
|
+
const defineProperty = Object.defineProperty;
|
|
170
|
+
let defineSpy: { mockRestore(): void };
|
|
171
|
+
|
|
172
|
+
beforeEach(() => {
|
|
173
|
+
defineSpy = vi
|
|
174
|
+
.spyOn(Object, 'defineProperty')
|
|
175
|
+
.mockImplementation((target, key, descriptor) =>
|
|
176
|
+
defineProperty(target, key, target === window ? { ...descriptor, configurable: true } : descriptor),
|
|
177
|
+
);
|
|
178
|
+
delete (window as any).installedModules;
|
|
179
|
+
delete (window as any).applicationVersion;
|
|
180
|
+
vi.useFakeTimers();
|
|
181
|
+
harness.reset();
|
|
182
|
+
harness.refetchCurrentUser.mockClear();
|
|
183
|
+
harness.renderServerStartingPage.mockReset().mockReturnValue(harness.removeServerStartingPage);
|
|
184
|
+
harness.removeServerStartingPage.mockClear();
|
|
185
|
+
document.body.innerHTML = `
|
|
186
|
+
<template id="app-error">
|
|
187
|
+
<div class="omrs-app-error"><code data-var="message"></code></div>
|
|
188
|
+
</template>`;
|
|
189
|
+
vi.stubGlobal('fetch', () => new Promise(() => {}));
|
|
190
|
+
run([]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
afterEach(() => {
|
|
194
|
+
vi.useRealTimers();
|
|
195
|
+
defineSpy.mockRestore();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const finishBoot = async () => {
|
|
199
|
+
harness.boot();
|
|
200
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
it('waits for the app to boot before it is shown', async () => {
|
|
204
|
+
harness.push(startingUp);
|
|
205
|
+
expect(harness.renderServerStartingPage).not.toHaveBeenCalled();
|
|
206
|
+
expect(errorPage()).toBeNull();
|
|
207
|
+
|
|
208
|
+
await finishBoot();
|
|
209
|
+
expect(harness.renderServerStartingPage).toHaveBeenCalledTimes(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('is shown once when the server is still starting after boot', async () => {
|
|
213
|
+
await finishBoot();
|
|
214
|
+
harness.push(startingUp);
|
|
215
|
+
harness.push(startingUp);
|
|
216
|
+
|
|
217
|
+
expect(harness.renderServerStartingPage).toHaveBeenCalledTimes(1);
|
|
218
|
+
expect(errorPage()).toBeNull();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('polls the session while the server is starting up, even before boot finishes', async () => {
|
|
222
|
+
harness.push(startingUp);
|
|
223
|
+
harness.push(startingUp);
|
|
224
|
+
expect(harness.refetchCurrentUser).not.toHaveBeenCalled();
|
|
225
|
+
|
|
226
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
227
|
+
expect(harness.refetchCurrentUser).toHaveBeenCalledTimes(1);
|
|
228
|
+
|
|
229
|
+
harness.push(startingUp);
|
|
230
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
231
|
+
expect(harness.refetchCurrentUser).toHaveBeenCalledTimes(2);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('is taken down when the session loads', async () => {
|
|
235
|
+
await finishBoot();
|
|
236
|
+
harness.push(startingUp);
|
|
237
|
+
harness.push({ loaded: true, session: { authenticated: false } });
|
|
238
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
239
|
+
|
|
240
|
+
expect(harness.removeServerStartingPage).toHaveBeenCalledTimes(1);
|
|
241
|
+
expect(harness.refetchCurrentUser).not.toHaveBeenCalled();
|
|
242
|
+
expect(harness.listeners.size).toBe(0);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('is never shown if the session loads before boot finishes', async () => {
|
|
246
|
+
harness.push(startingUp);
|
|
247
|
+
harness.push({ loaded: true, session: { authenticated: false } });
|
|
248
|
+
await finishBoot();
|
|
249
|
+
|
|
250
|
+
expect(harness.renderServerStartingPage).not.toHaveBeenCalled();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('is replaced by the error page when the session fails for another reason', async () => {
|
|
254
|
+
await finishBoot();
|
|
255
|
+
harness.push(startingUp);
|
|
256
|
+
harness.push({ loaded: false, session: null, error: new Error('Bad Gateway') });
|
|
257
|
+
|
|
258
|
+
expect(harness.removeServerStartingPage).toHaveBeenCalledTimes(1);
|
|
259
|
+
expect(errorPage()?.textContent).toBe('Bad Gateway');
|
|
260
|
+
});
|
|
111
261
|
});
|
package/src/run.ts
CHANGED
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
integrateBreakpoints,
|
|
13
13
|
interpolateUrl,
|
|
14
14
|
type OpenmrsRoutes,
|
|
15
|
+
type SessionStore,
|
|
15
16
|
provide,
|
|
17
|
+
refetchCurrentUser,
|
|
16
18
|
registerApp,
|
|
17
19
|
registerDefaultCalendar,
|
|
18
20
|
renderActionableNotifications,
|
|
@@ -39,12 +41,14 @@ import {
|
|
|
39
41
|
} from '@openmrs/esm-framework/src/internal';
|
|
40
42
|
import { setupStyleguide } from '@openmrs/esm-styleguide/src/index';
|
|
41
43
|
import { setupI18n } from './locale';
|
|
44
|
+
import { translateStaticPage } from './static-page-translations';
|
|
42
45
|
// imported so we create the MF shares for these
|
|
43
46
|
import 'swr/mutation';
|
|
44
47
|
import 'swr/subscription';
|
|
45
48
|
import './routing-events';
|
|
46
49
|
import './events';
|
|
47
50
|
import { appName, getCoreExtensions } from './ui';
|
|
51
|
+
import { renderServerStartingPage } from './ui/server-starting.component';
|
|
48
52
|
import { setupCoreConfig } from './core-config';
|
|
49
53
|
|
|
50
54
|
// @internal
|
|
@@ -132,20 +136,60 @@ function handleInitFailure(e: Error) {
|
|
|
132
136
|
renderFatalErrorPage(e);
|
|
133
137
|
}
|
|
134
138
|
|
|
139
|
+
const initializingPollIntervalMillis = 5000;
|
|
140
|
+
|
|
135
141
|
/**
|
|
136
142
|
* No app can render without a session, so a session fetch that fails before any session has loaded
|
|
137
143
|
* gets the same error page as a failed startup. Reading the session store retries the fetch, so the
|
|
138
144
|
* page is taken down again if a later fetch succeeds, e.g. once a backend that was starting up is ready.
|
|
145
|
+
*
|
|
146
|
+
* A backend that is still running its initial setup gets a "starting up" page instead, and the session
|
|
147
|
+
* is refetched periodically until setup finishes or the fetch fails some other way. That page is built
|
|
148
|
+
* from the framework, so it waits for `booted`; until then, the boot spinner stays up.
|
|
139
149
|
*/
|
|
140
|
-
function showErrorPageOnSessionFailure() {
|
|
141
|
-
let
|
|
142
|
-
|
|
150
|
+
function showErrorPageOnSessionFailure(booted: Promise<void>) {
|
|
151
|
+
let removePage: (() => void) | undefined;
|
|
152
|
+
let shownPage: 'initializing' | 'error' | undefined;
|
|
153
|
+
let pollTimer: ReturnType<typeof setTimeout> | undefined;
|
|
154
|
+
let isBooted = false;
|
|
155
|
+
let isDone = false;
|
|
156
|
+
|
|
157
|
+
const show = (page: typeof shownPage, render: () => (() => void) | undefined) => {
|
|
158
|
+
if (shownPage !== page) {
|
|
159
|
+
removePage?.();
|
|
160
|
+
removePage = render();
|
|
161
|
+
shownPage = page;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const update = (state: SessionStore) => {
|
|
166
|
+
if (isDone) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
143
170
|
if (state.loaded) {
|
|
171
|
+
isDone = true;
|
|
144
172
|
unsubscribe();
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
173
|
+
clearTimeout(pollTimer);
|
|
174
|
+
removePage?.();
|
|
175
|
+
} else if (state.initializing) {
|
|
176
|
+
if (isBooted) {
|
|
177
|
+
show('initializing', renderServerStartingPage);
|
|
178
|
+
}
|
|
179
|
+
pollTimer ??= setTimeout(() => {
|
|
180
|
+
pollTimer = undefined;
|
|
181
|
+
refetchCurrentUser().catch(() => {});
|
|
182
|
+
}, initializingPollIntervalMillis);
|
|
183
|
+
} else if (state.error) {
|
|
184
|
+
show('error', () => renderFatalErrorPage(state.error));
|
|
148
185
|
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const sessionStore = getSessionStore();
|
|
189
|
+
const unsubscribe = sessionStore.subscribe(update);
|
|
190
|
+
void booted.then(() => {
|
|
191
|
+
isBooted = true;
|
|
192
|
+
update(sessionStore.getState());
|
|
149
193
|
});
|
|
150
194
|
}
|
|
151
195
|
|
|
@@ -161,6 +205,11 @@ function renderFatalErrorPage(e?: Error): (() => void) | undefined {
|
|
|
161
205
|
messageContainer.textContent = e?.message || 'No additional information available.';
|
|
162
206
|
}
|
|
163
207
|
|
|
208
|
+
const copyButton = fragment.querySelector<HTMLButtonElement>('[data-copy-btn]');
|
|
209
|
+
if (copyButton && messageContainer) {
|
|
210
|
+
copyButton.onclick = () => copyWithFeedback(messageContainer as HTMLElement, copyButton);
|
|
211
|
+
}
|
|
212
|
+
|
|
164
213
|
if (
|
|
165
214
|
localStorage.getItem('openmrs:devtools') &&
|
|
166
215
|
Object.keys(localStorage).some((k) => k.startsWith('import-map-override:'))
|
|
@@ -170,17 +219,31 @@ function renderFatalErrorPage(e?: Error): (() => void) | undefined {
|
|
|
170
219
|
const clearDevOverridesButton = document.createElement('button');
|
|
171
220
|
clearDevOverridesButton.className = 'cds--btn';
|
|
172
221
|
clearDevOverridesButton.innerHTML = 'Clear dev overrides';
|
|
222
|
+
clearDevOverridesButton.dataset.i18n = 'clearDevOverrides';
|
|
173
223
|
clearDevOverridesButton.onclick = clearDevOverrides;
|
|
174
224
|
appErrorActionButtons.appendChild(clearDevOverridesButton);
|
|
175
225
|
}
|
|
176
226
|
}
|
|
177
227
|
|
|
228
|
+
translateStaticPage(fragment);
|
|
229
|
+
|
|
178
230
|
const nodes = Array.from(fragment.childNodes);
|
|
179
231
|
document.body.appendChild(fragment);
|
|
180
232
|
return () => nodes.forEach((node) => node.remove());
|
|
181
233
|
}
|
|
182
234
|
}
|
|
183
235
|
|
|
236
|
+
const copyFeedbackMillis = 2000;
|
|
237
|
+
|
|
238
|
+
/** Copies the text of `source` and briefly shows the "Copied!" tooltip attached to `button`. */
|
|
239
|
+
function copyWithFeedback(source: HTMLElement, button: HTMLElement) {
|
|
240
|
+
window.copyText(source);
|
|
241
|
+
|
|
242
|
+
const tooltip = button.closest('.cds--popover-container');
|
|
243
|
+
tooltip?.classList.add('cds--popover--open');
|
|
244
|
+
setTimeout(() => tooltip?.classList.remove('cds--popover--open'), copyFeedbackMillis);
|
|
245
|
+
}
|
|
246
|
+
|
|
184
247
|
function clearDevOverrides() {
|
|
185
248
|
const keysToRemove = Object.keys(localStorage).filter(
|
|
186
249
|
(key) =>
|
|
@@ -272,7 +335,9 @@ export function run(configUrls: Array<string>) {
|
|
|
272
335
|
subscribeToastShown(showToast);
|
|
273
336
|
subscribeSnackbarShown(showSnackbar);
|
|
274
337
|
setupApiModule();
|
|
275
|
-
|
|
338
|
+
|
|
339
|
+
let markBooted: () => void;
|
|
340
|
+
showErrorPageOnSessionFailure(new Promise<void>((resolve) => (markBooted = resolve)));
|
|
276
341
|
setupHistory();
|
|
277
342
|
registerCoreExtensions();
|
|
278
343
|
setupCoreConfig();
|
|
@@ -290,6 +355,7 @@ export function run(configUrls: Array<string>) {
|
|
|
290
355
|
.then(() => Promise.resolve(finishRegisteringAllApps()))
|
|
291
356
|
.then(provideConfigs)
|
|
292
357
|
.then(runShell)
|
|
358
|
+
.then(() => markBooted())
|
|
293
359
|
.catch(handleInitFailure)
|
|
294
360
|
.then(closeLoading)
|
|
295
361
|
.then(() => {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('@openmrs/esm-translations/translations/fr.json', () => ({
|
|
4
|
+
default: { reload: 'Recharger', copied: '' },
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
vi.mock('@openmrs/esm-translations/translations/pt_BR.json', () => ({
|
|
8
|
+
default: { reload: 'Recarregar' },
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
function createPage() {
|
|
12
|
+
const template = document.createElement('template');
|
|
13
|
+
template.innerHTML = `
|
|
14
|
+
<button data-i18n="reload">Reload</button>
|
|
15
|
+
<span data-i18n="copied">Copied!</span>
|
|
16
|
+
<span data-i18n="notAKey">Untouched</span>`;
|
|
17
|
+
return template.content;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function translate(lang: string) {
|
|
21
|
+
document.documentElement.setAttribute('lang', lang);
|
|
22
|
+
// The translations are loaded once per page, so each language needs a fresh module.
|
|
23
|
+
vi.resetModules();
|
|
24
|
+
const { translateStaticPage } = await import('./static-page-translations');
|
|
25
|
+
|
|
26
|
+
const page = createPage();
|
|
27
|
+
translateStaticPage(page);
|
|
28
|
+
await vi.dynamicImportSettled();
|
|
29
|
+
await new Promise((resolve) => setTimeout(resolve));
|
|
30
|
+
return page;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('translateStaticPage', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
localStorage.clear();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('replaces the text of tagged elements with the translation for the detected language', async () => {
|
|
39
|
+
const page = await translate('fr');
|
|
40
|
+
|
|
41
|
+
expect(page.querySelector('button')?.textContent).toBe('Recharger');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('keeps the English text for keys that are missing or untranslated', async () => {
|
|
45
|
+
const page = await translate('fr');
|
|
46
|
+
|
|
47
|
+
expect(page.querySelector('[data-i18n="copied"]')?.textContent).toBe('Copied!');
|
|
48
|
+
expect(page.querySelector('[data-i18n="notAKey"]')?.textContent).toBe('Untouched');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('loads the file named with an underscore for a regional language', async () => {
|
|
52
|
+
const page = await translate('pt-BR');
|
|
53
|
+
|
|
54
|
+
expect(page.querySelector('button')?.textContent).toBe('Recarregar');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('falls back to the base language when there is no regional file', async () => {
|
|
58
|
+
const page = await translate('fr-CA');
|
|
59
|
+
|
|
60
|
+
expect(page.querySelector('button')?.textContent).toBe('Recharger');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('keeps the English text when the language has no translations', async () => {
|
|
64
|
+
const page = await translate('xx');
|
|
65
|
+
|
|
66
|
+
expect(page.querySelector('button')?.textContent).toBe('Reload');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import LanguageDetector from 'i18next-browser-languagedetector';
|
|
2
|
+
import { languageDetectionOptions } from './language-detection';
|
|
3
|
+
|
|
4
|
+
let translations: Promise<Record<string, string>> | undefined;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Translates the pages the shell renders from `index.ejs` templates, such as the error page. These can
|
|
8
|
+
* appear before i18next is set up, or because setting it up failed, so the core translations are
|
|
9
|
+
* loaded directly for the language i18next would detect, without config overrides or interpolation.
|
|
10
|
+
*
|
|
11
|
+
* Each element with a `data-i18n` attribute has its text replaced by the core translation of that key
|
|
12
|
+
* once the translations load. Until then, or if they can't be loaded, the template's English text stays.
|
|
13
|
+
*/
|
|
14
|
+
export function translateStaticPage(root: ParentNode) {
|
|
15
|
+
const elements = Array.from(root.querySelectorAll<HTMLElement>('[data-i18n]'));
|
|
16
|
+
if (elements.length === 0) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
void loadTranslations().then((loaded) => {
|
|
21
|
+
for (const element of elements) {
|
|
22
|
+
const translation = loaded[element.dataset.i18n!];
|
|
23
|
+
if (translation) {
|
|
24
|
+
element.textContent = translation;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadTranslations() {
|
|
31
|
+
translations ??= (async () => {
|
|
32
|
+
const detected = new LanguageDetector(undefined, languageDetectionOptions).detect();
|
|
33
|
+
const language = Array.isArray(detected) ? detected[0] : detected;
|
|
34
|
+
if (!language) {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Translation files are named with underscores, e.g. `pt_BR.json`.
|
|
39
|
+
const candidates = new Set([language.replace('-', '_'), language.split(/[-_]/)[0]]);
|
|
40
|
+
for (const candidate of candidates) {
|
|
41
|
+
try {
|
|
42
|
+
const json = await import(/* webpackMode: "lazy" */ `@openmrs/esm-translations/translations/${candidate}.json`);
|
|
43
|
+
return (json.default ?? json) as Record<string, string>;
|
|
44
|
+
} catch {
|
|
45
|
+
// Try the next candidate
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {};
|
|
50
|
+
})();
|
|
51
|
+
|
|
52
|
+
return translations;
|
|
53
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { Link, Loading, Tile } from '@carbon/react';
|
|
4
|
+
import {
|
|
5
|
+
getConfig,
|
|
6
|
+
getCoreTranslation,
|
|
7
|
+
interpolateUrl,
|
|
8
|
+
makeUrl,
|
|
9
|
+
type StyleguideConfigObject,
|
|
10
|
+
} from '@openmrs/esm-framework/src/internal';
|
|
11
|
+
|
|
12
|
+
/** How long the server may take to start before the page tells the user it is taking longer than usual. */
|
|
13
|
+
export const slowStartupMillis = 30 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
interface ServerStartingPageProps {
|
|
16
|
+
logo: StyleguideConfigObject['logo'];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ServerStartingPage({ logo }: ServerStartingPageProps) {
|
|
20
|
+
const [isSlow, setIsSlow] = useState(false);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const timer = setTimeout(() => setIsSlow(true), slowStartupMillis);
|
|
24
|
+
return () => clearTimeout(timer);
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Tile className="omrs-server-starting__tile">
|
|
29
|
+
{logo?.src ? (
|
|
30
|
+
<img className="omrs-server-starting__logo" alt={logo.alt} src={interpolateUrl(logo.src)} />
|
|
31
|
+
) : (
|
|
32
|
+
<svg role="img" className="omrs-server-starting__logo">
|
|
33
|
+
<title>OpenMRS</title>
|
|
34
|
+
<use href="#omrs-logo-full-color"></use>
|
|
35
|
+
</svg>
|
|
36
|
+
)}
|
|
37
|
+
<Loading withOverlay={false} description={getCoreTranslation('waitingForServer')} />
|
|
38
|
+
<div className="omrs-server-starting__text" aria-live="polite">
|
|
39
|
+
<h1>{getCoreTranslation('serverStartingUp')}</h1>
|
|
40
|
+
<p>
|
|
41
|
+
{isSlow
|
|
42
|
+
? getCoreTranslation('serverStartingUpSlowExplainer')
|
|
43
|
+
: getCoreTranslation('serverStartingUpExplainer')}
|
|
44
|
+
</p>
|
|
45
|
+
</div>
|
|
46
|
+
<Link href={makeUrl('/initialsetup')}>{getCoreTranslation('viewSetupProgress')}</Link>
|
|
47
|
+
</Tile>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Renders the page shown while the backend is running its initial setup and returns a function that removes it.
|
|
53
|
+
* It relies on the styleguide config and translations, so it should only be rendered once the app has booted.
|
|
54
|
+
*/
|
|
55
|
+
export function renderServerStartingPage(): () => void {
|
|
56
|
+
const container = document.createElement('div');
|
|
57
|
+
container.className = 'omrs-server-starting';
|
|
58
|
+
document.body.appendChild(container);
|
|
59
|
+
|
|
60
|
+
const root = createRoot(container);
|
|
61
|
+
let removed = false;
|
|
62
|
+
|
|
63
|
+
getConfig<StyleguideConfigObject>('@openmrs/esm-styleguide').then(({ logo }) => {
|
|
64
|
+
if (!removed) {
|
|
65
|
+
root.render(<ServerStartingPage logo={logo} />);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return () => {
|
|
70
|
+
removed = true;
|
|
71
|
+
root.unmount();
|
|
72
|
+
container.remove();
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, render, screen } from '@testing-library/react';
|
|
4
|
+
|
|
5
|
+
vi.mock('@openmrs/esm-framework/src/internal', () => ({
|
|
6
|
+
getConfig: vi.fn(),
|
|
7
|
+
getCoreTranslation: (key: string) => key,
|
|
8
|
+
interpolateUrl: (url: string) => url.replace('${openmrsBase}', '/openmrs'),
|
|
9
|
+
makeUrl: (path: string) => `/openmrs${path}`,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import { ServerStartingPage, slowStartupMillis } from './server-starting.component';
|
|
13
|
+
|
|
14
|
+
describe('ServerStartingPage', () => {
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.useRealTimers();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('shows the default logo, a spinner, and a link to the setup page', () => {
|
|
20
|
+
render(<ServerStartingPage logo={{ src: '', alt: 'Logo' }} />);
|
|
21
|
+
|
|
22
|
+
expect(screen.getByRole('img', { name: 'OpenMRS' })).toBeInTheDocument();
|
|
23
|
+
expect(screen.getByTitle('waitingForServer')).toBeInTheDocument();
|
|
24
|
+
expect(screen.getByRole('heading', { name: 'serverStartingUp' })).toBeInTheDocument();
|
|
25
|
+
expect(screen.getByText('serverStartingUpExplainer')).toBeInTheDocument();
|
|
26
|
+
expect(screen.getByRole('link', { name: 'viewSetupProgress' })).toHaveAttribute('href', '/openmrs/initialsetup');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('shows the configured logo', () => {
|
|
30
|
+
render(<ServerStartingPage logo={{ src: '${openmrsBase}/brand.png', alt: 'Brand logo' }} />);
|
|
31
|
+
|
|
32
|
+
expect(screen.getByAltText('Brand logo')).toHaveAttribute('src', '/openmrs/brand.png');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('says the startup is taking longer than usual once it has been waiting a long time', () => {
|
|
36
|
+
vi.useFakeTimers();
|
|
37
|
+
render(<ServerStartingPage logo={{ src: '', alt: 'Logo' }} />);
|
|
38
|
+
|
|
39
|
+
act(() => vi.advanceTimersByTime(slowStartupMillis - 1));
|
|
40
|
+
expect(screen.getByText('serverStartingUpExplainer')).toBeInTheDocument();
|
|
41
|
+
|
|
42
|
+
act(() => vi.advanceTimersByTime(1));
|
|
43
|
+
expect(screen.getByText('serverStartingUpSlowExplainer')).toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
});
|
package/vitest.config.ts
CHANGED
|
@@ -5,6 +5,8 @@ export default defineConfig({
|
|
|
5
5
|
resolve: {
|
|
6
6
|
alias: {
|
|
7
7
|
'@openmrs/esm-framework/src/internal': resolve(__dirname, '../../framework/esm-emr-api/src/events/index.ts'),
|
|
8
|
+
// Mirrors the alias in rspack.config.js; the package doesn't export its translation files.
|
|
9
|
+
'@openmrs/esm-translations/translations': resolve(__dirname, '../../framework/esm-translations/translations'),
|
|
8
10
|
},
|
|
9
11
|
},
|
|
10
12
|
test: {
|
|
@@ -15,6 +17,7 @@ export default defineConfig({
|
|
|
15
17
|
},
|
|
16
18
|
},
|
|
17
19
|
mockReset: true,
|
|
20
|
+
setupFiles: ['./setup-tests.ts'],
|
|
18
21
|
// `browser-targets.test.ts` builds the real rspack config, which compiles the styleguide stylesheet
|
|
19
22
|
// through sass; on CI that alone can exceed vitest's unit-test-sized default.
|
|
20
23
|
testTimeout: 20_000,
|
package/dist/05ce2d37a1daa834.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"use strict";(globalThis.webpackChunk_openmrs_esm_app_shell=globalThis.webpackChunk_openmrs_esm_app_shell||[]).push([["6590"],{7581(e){e.exports=JSON.parse('{"abnormalValue":"Abnormal value","actionableNotification":"Actionable notification","actions":"Actions","address":"Address","address1":"Address line 1","address2":"Address line 2","address3":"Address line 3","address4":"Address line 4","address5":"Address line 5","address6":"Address line 6","age":"Age","batchActionItemSelected":"{{totalSelected}} item selected","batchActionsItemsSelected":"{{totalSelected}} items selected","cancel":"Cancel","change":"Change","city":"City","cityVillage":"City","Clinic":"Clinic","close":"Close","closeAllOpenedWorkspaces":"Discard changes in {{count}} workspaces","closesActionableNotification":"Close actionable notification","closeSnackbar":"Close snackbar","closingAllWorkspacesPromptBody":"There may be unsaved changes in the following workspaces. Do you want to discard changes in the following workspaces? {{workspaceNames}}","closingAllWorkspacesPromptTitle":"You have unsaved changes","confirm":"Confirm","contactAdministratorIfIssuePersists":"Contact your system administrator if the problem persists.","contactDetails":"Contact Details","country":"Country","countyDistrict":"District","delete":"Delete","discard":"Discard","discardChanges":"Discard changes","discardUnsavedChangesPromptBodyMultiple":"{{count}} workspaces have unsaved changes. Closing them will discard the changes:","discardUnsavedChangesPromptBodySingle":"<strong>{{workspaceTitle}}</strong> has unsaved changes. Closing it will discard them.","discardUnsavedChangesPromptTitle":"Discard unsaved changes?","district":"District","edit":"Edit","emptyStateText":"There are no {{displayText}} to display","error":"Error","errorCopy":"Sorry, there was a problem displaying this information. You can try to reload this page, or contact the site administrator and quote the error code above.","errorLoadingLoginLocations":"Unable to load login locations. Please try again or contact support if the problem persists.","female":"Female","hide":"Hide","keepEditing":"Keep editing","leftNavigation":"Left navigation","loading":"Loading","localVersion":"Local Build","male":"Male","maximize":"Maximize","minimize":"Minimize","navigateNonHttp":"Could not navigate to the requested page as it is not a safe URL","noResultsToDisplay":"No results to display","notAvailable":"Not available","openAnyway":"Open Anyway","other":"Other","paginationItemsCount":"{{pageItemsCount}} / {{totalItems}} items","paginationOfPages":"of {{count}} pages","patientAvatarAlt":"Avatar for {{patientName}}","patientIdentifierSticker":"Patient identifier sticker","patientLists":"Patient Lists","patientPhotoAlt":"Profile photo of {{patientName}}","patientPhotoPlaceholder":"Photo placeholder for {{patientName}}","postalCode":"Postal code","prereleaseVersion":"Prerelease","print":"Print","printError":"Print error","printErrorExplainer":"An error occurred in {{errorLocation}}","printIdentifierSticker":"Print identifier sticker","printing":"Printing","recordNewEntry":"Record {{displayText}}","relationships":"Relationships","resetOverrides":"Reset overrides","save":"Save","scriptLoadingError":"Failed to load overridden script from {{url}}. Please check that the bundled script is available at the expected URL. Click the button below to reset all import map overrides.","scriptLoadingFailed":"Error: Script failed to load","searchForLocation":"Search for a location","seeAll":"See all","seeMoreLists":"See {{count}} more lists","selectAll":"Select all","sex":"Sex","showLess":"Show less","showMore":"Show more","snackbarNotification":"Snackbar notification","state":"State","stateProvince":"State","toggleDevTools":"Toggle dev tools","unknown":"Unknown","unsavedChangesInOpenedWorkspace":"You may have unsaved changes in the opened workspace. Do you want to discard these changes?","unsavedChangesInWorkspace":"There may be unsaved changes in \\"{{workspaceName}}\\". Please save them before opening another workspace.","unsavedChangesTitleText":"Unsaved changes","workspaceHeader":"Workspace header","yearAbbreviation":"yr","yearsAbbreviation":"yrs"}')}}]);
|