@mherman22/esm-act-app 1.0.1-pre.22
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/.editorconfig +12 -0
- package/.husky/pre-commit +7 -0
- package/.husky/pre-push +6 -0
- package/.prettierignore +14 -0
- package/.turbo.json +18 -0
- package/.yarn/plugins/@yarnpkg/plugin-outdated.cjs +35 -0
- package/LICENSE +401 -0
- package/README.md +115 -0
- package/SECURITY.md +12 -0
- package/__mocks__/react-i18next.js +50 -0
- package/dist/117.js +1 -0
- package/dist/117.js.map +1 -0
- package/dist/131.css +1 -0
- package/dist/131.css.map +1 -0
- package/dist/131.js +1 -0
- package/dist/131.js.map +1 -0
- package/dist/197.js +1 -0
- package/dist/197.js.map +1 -0
- package/dist/237.js +1 -0
- package/dist/237.js.map +1 -0
- package/dist/29.js +1 -0
- package/dist/29.js.map +1 -0
- package/dist/338.css +1 -0
- package/dist/338.css.map +1 -0
- package/dist/338.js +1 -0
- package/dist/338.js.map +1 -0
- package/dist/362.js +1 -0
- package/dist/362.js.map +1 -0
- package/dist/379.js +1 -0
- package/dist/379.js.map +1 -0
- package/dist/410.js +1 -0
- package/dist/410.js.map +1 -0
- package/dist/430.css +1 -0
- package/dist/430.css.map +1 -0
- package/dist/430.js +1 -0
- package/dist/430.js.map +1 -0
- package/dist/450.css +1 -0
- package/dist/450.css.map +1 -0
- package/dist/450.js +1 -0
- package/dist/450.js.map +1 -0
- package/dist/517.js +1 -0
- package/dist/517.js.map +1 -0
- package/dist/538.js +1 -0
- package/dist/538.js.map +1 -0
- package/dist/578.js +1 -0
- package/dist/578.js.map +1 -0
- package/dist/638.js +1 -0
- package/dist/638.js.map +1 -0
- package/dist/708.css +1 -0
- package/dist/708.css.map +1 -0
- package/dist/708.js +1 -0
- package/dist/708.js.map +1 -0
- package/dist/749.js +1 -0
- package/dist/749.js.map +1 -0
- package/dist/823.css +1 -0
- package/dist/823.css.map +1 -0
- package/dist/823.js +1 -0
- package/dist/823.js.map +1 -0
- package/dist/898.js +1 -0
- package/dist/898.js.map +1 -0
- package/dist/943.js +1 -0
- package/dist/943.js.map +1 -0
- package/dist/957.css +1 -0
- package/dist/957.css.map +1 -0
- package/dist/957.js +43 -0
- package/dist/957.js.map +1 -0
- package/dist/977.js +1 -0
- package/dist/977.js.map +1 -0
- package/dist/98.js +1 -0
- package/dist/98.js.map +1 -0
- package/dist/997.js +1 -0
- package/dist/997.js.map +1 -0
- package/dist/999.js +6 -0
- package/dist/999.js.map +1 -0
- package/dist/mherman22-esm-act-app.js +3 -0
- package/dist/mherman22-esm-act-app.js.buildmanifest.json +781 -0
- package/dist/mherman22-esm-act-app.js.map +1 -0
- package/dist/routes.json +1 -0
- package/eslint.config.mjs +20 -0
- package/package.json +99 -0
- package/prettier.config.js +8 -0
- package/rspack.config.js +1 -0
- package/src/access/act-dashboard-link.component.tsx +16 -0
- package/src/access/screen-access.component.tsx +15 -0
- package/src/access/screen-access.test.tsx +81 -0
- package/src/access/sign-in.test-helper.ts +24 -0
- package/src/act-home/act-home-card.component.tsx +27 -0
- package/src/act-home/act-home-card.scss +25 -0
- package/src/act-home/act-home-dashboard-link.component.tsx +7 -0
- package/src/act-home/act-home-dashboard-link.test.tsx +32 -0
- package/src/act-home/act-home-dashboard.component.tsx +30 -0
- package/src/act-home/act-home-dashboard.test.tsx +43 -0
- package/src/act-home/act-home.meta.ts +8 -0
- package/src/act-home/act-home.scss +22 -0
- package/src/act-home/care-cascade.component.tsx +77 -0
- package/src/act-home/care-cascade.scss +42 -0
- package/src/act-home/care-cascade.test.tsx +128 -0
- package/src/act-home/quick-actions.component.tsx +32 -0
- package/src/act-home/quick-actions.scss +32 -0
- package/src/act-home/quick-actions.test.tsx +51 -0
- package/src/act-home/worklist-tiles.component.tsx +69 -0
- package/src/act-home/worklist-tiles.scss +50 -0
- package/src/act-home/worklist-tiles.test.tsx +133 -0
- package/src/config-schema.test.ts +83 -0
- package/src/config-schema.ts +129 -0
- package/src/declarations.d.ts +19 -0
- package/src/fetch-all.ts +20 -0
- package/src/flag-gaps/flag-gaps.resource.test.ts +57 -0
- package/src/flag-gaps/flag-gaps.resource.ts +69 -0
- package/src/flag-gaps/flag-gaps.scss +19 -0
- package/src/flag-gaps/flag-gaps.workspace.test.tsx +208 -0
- package/src/flag-gaps/flag-gaps.workspace.tsx +149 -0
- package/src/index.ts +33 -0
- package/src/registry/csv.ts +16 -0
- package/src/registry/registry-bpg.test.tsx +84 -0
- package/src/registry/registry-csv.test.tsx +100 -0
- package/src/registry/registry-dashboard-link.component.tsx +7 -0
- package/src/registry/registry-dashboard-link.test.tsx +32 -0
- package/src/registry/registry-filters.test.tsx +218 -0
- package/src/registry/registry-filters.ts +81 -0
- package/src/registry/registry-flags.test.tsx +67 -0
- package/src/registry/registry.component.tsx +255 -0
- package/src/registry/registry.fixture.ts +14 -0
- package/src/registry/registry.meta.ts +6 -0
- package/src/registry/registry.scss +41 -0
- package/src/registry/registry.test.tsx +147 -0
- package/src/reports/report-dataset.resource.test.tsx +189 -0
- package/src/reports/report-dataset.resource.ts +51 -0
- package/src/reports/report-date.test.ts +18 -0
- package/src/reports/report-date.ts +12 -0
- package/src/rhd-flags/rhd-flag-lists.resource.test.tsx +240 -0
- package/src/rhd-flags/rhd-flag-lists.resource.ts +88 -0
- package/src/routes.json +66 -0
- package/src/routes.test.ts +32 -0
- package/tools/i18next-parser.config.js +89 -0
- package/tools/setup-tests.ts +5 -0
- package/tools/update-openmrs-deps.mjs +42 -0
- package/translations/en.json +52 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +21 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getDefaultsFromConfigSchema } from '@openmrs/esm-framework';
|
|
3
|
+
import { configSchema } from './config-schema';
|
|
4
|
+
|
|
5
|
+
describe('configSchema', () => {
|
|
6
|
+
it('defaults to the ACT privilege, clinic tags, RHD flags and ACT 2.0 urgency bands', () => {
|
|
7
|
+
expect(getDefaultsFromConfigSchema(configSchema)).toEqual({
|
|
8
|
+
screenPrivileges: {
|
|
9
|
+
home: 'View Patient Flags',
|
|
10
|
+
registry: 'View Patient Flags',
|
|
11
|
+
waitingList: 'View Patient Flags',
|
|
12
|
+
screenPositive: 'View Patient Flags',
|
|
13
|
+
},
|
|
14
|
+
clinicLocationTags: {
|
|
15
|
+
cardiac: ['RHD Tertiary', 'RHD District'],
|
|
16
|
+
primaryCare: ['RHD Community'],
|
|
17
|
+
},
|
|
18
|
+
flagLists: {
|
|
19
|
+
namePrefix: 'RHD ',
|
|
20
|
+
names: [],
|
|
21
|
+
riskFlags: ['RHD prophylaxis overdue', 'RHD lost to follow-up'],
|
|
22
|
+
},
|
|
23
|
+
quickActions: {
|
|
24
|
+
registerPatientUrl: '${openmrsSpaBase}/patient-registration',
|
|
25
|
+
enterProphylaxisUrl: '${openmrsSpaBase}/forms',
|
|
26
|
+
findPatientUrl: '${openmrsSpaBase}/search',
|
|
27
|
+
},
|
|
28
|
+
careCascade: {
|
|
29
|
+
report: '9c6751ae-65fc-5f25-9aa6-8c65cb1dff68',
|
|
30
|
+
reportUrl: '${openmrsSpaBase}/reports',
|
|
31
|
+
steps: ['Active', 'Prescribed Prophylaxis', 'Initiated BPG', 'Adherent'],
|
|
32
|
+
},
|
|
33
|
+
registry: {
|
|
34
|
+
report: 'f1a2b3c4-d5e6-7890-abcd-ef1234567890',
|
|
35
|
+
showBpgColumns: false,
|
|
36
|
+
},
|
|
37
|
+
urgencyBands: [
|
|
38
|
+
{ label: '1: Emergent', concept: '1fe15210-4490-58b0-a38c-bb0386e98482', deadlineDays: 1 },
|
|
39
|
+
{ label: '2: Urgent', concept: '33bf504a-15f2-5504-9bdc-ddded0b5eb00', deadlineDays: 60 },
|
|
40
|
+
{ label: '3: Elective', concept: '2666bf97-7400-57c7-b535-7903e22ced34', deadlineDays: 180 },
|
|
41
|
+
],
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('urgencyBands', () => {
|
|
47
|
+
it.each([
|
|
48
|
+
['its deadline', { label: '2: Urgent', concept: '33bf504a-15f2-5504-9bdc-ddded0b5eb00' }],
|
|
49
|
+
['its concept', { label: '2: Urgent', deadlineDays: 60 }],
|
|
50
|
+
])('reports a band that leaves out %s', async (_, band) => {
|
|
51
|
+
const { clearConfigErrors, processConfig } =
|
|
52
|
+
await vi.importActual<typeof import('@openmrs/esm-config')>('@openmrs/esm-config');
|
|
53
|
+
clearConfigErrors();
|
|
54
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
55
|
+
|
|
56
|
+
processConfig(configSchema, { urgencyBands: [band] }, 'rhd');
|
|
57
|
+
|
|
58
|
+
const messages = consoleError.mock.calls.map((call) => String(call[0]));
|
|
59
|
+
expect(messages.some((message) => message.includes('Every urgency band needs'))).toBe(true);
|
|
60
|
+
consoleError.mockRestore();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('reports a band whose deadline is not a number or whose key is misspelled', async () => {
|
|
64
|
+
const { processConfig } = await vi.importActual<typeof import('@openmrs/esm-config')>('@openmrs/esm-config');
|
|
65
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
66
|
+
|
|
67
|
+
processConfig(
|
|
68
|
+
configSchema,
|
|
69
|
+
{
|
|
70
|
+
urgencyBands: [
|
|
71
|
+
{ label: '2: Urgent', concept: '33bf504a-15f2-5504-9bdc-ddded0b5eb00', deadlineDays: '60' },
|
|
72
|
+
{ label: '3: Elective', concept: '2666bf97-7400-57c7-b535-7903e22ced34', deadline: 180 },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
'rhd',
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const messages = consoleError.mock.calls.map((call) => String(call[0]));
|
|
79
|
+
expect(messages.some((message) => message.includes('urgencyBands[0].deadlineDays'))).toBe(true);
|
|
80
|
+
expect(messages.some((message) => message.includes('urgencyBands[1].deadline'))).toBe(true);
|
|
81
|
+
consoleError.mockRestore();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Type, validator } from '@openmrs/esm-framework';
|
|
2
|
+
|
|
3
|
+
const screenPrivilege = (screen: string) => ({
|
|
4
|
+
_type: Type.String,
|
|
5
|
+
_default: 'View Patient Flags',
|
|
6
|
+
_description: `The privilege a user needs to see ${screen}, its menu entry and its widgets.`,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const locationTags = (clinics: string, defaultTags: Array<string>) => ({
|
|
10
|
+
_type: Type.Array,
|
|
11
|
+
_elements: { _type: Type.String },
|
|
12
|
+
_default: defaultTags,
|
|
13
|
+
_description: `Location tags that mark ${clinics}.`,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const link = (description: string, defaultUrl: string) => ({
|
|
17
|
+
_type: Type.String,
|
|
18
|
+
_default: defaultUrl,
|
|
19
|
+
_description: description,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const configSchema = {
|
|
23
|
+
screenPrivileges: {
|
|
24
|
+
home: screenPrivilege('the ACT home page'),
|
|
25
|
+
registry: screenPrivilege('the registry list'),
|
|
26
|
+
waitingList: screenPrivilege('the procedural waiting list'),
|
|
27
|
+
screenPositive: screenPrivilege('the screen positive, pending confirmation page'),
|
|
28
|
+
},
|
|
29
|
+
clinicLocationTags: {
|
|
30
|
+
cardiac: locationTags('cardiac clinics', ['RHD Tertiary', 'RHD District']),
|
|
31
|
+
primaryCare: locationTags('primary care clinics', ['RHD Community']),
|
|
32
|
+
},
|
|
33
|
+
flagLists: {
|
|
34
|
+
namePrefix: {
|
|
35
|
+
_type: Type.String,
|
|
36
|
+
_default: 'RHD ',
|
|
37
|
+
_description: 'The worklist tiles show the lists of the flags whose names start with this, unless names is set.',
|
|
38
|
+
},
|
|
39
|
+
names: {
|
|
40
|
+
_type: Type.Array,
|
|
41
|
+
_elements: { _type: Type.String },
|
|
42
|
+
_default: [],
|
|
43
|
+
_description: 'The flags whose lists the worklist tiles show, by name. When set, namePrefix is not used.',
|
|
44
|
+
},
|
|
45
|
+
riskFlags: {
|
|
46
|
+
_type: Type.Array,
|
|
47
|
+
_elements: { _type: Type.String },
|
|
48
|
+
_default: ['RHD prophylaxis overdue', 'RHD lost to follow-up'],
|
|
49
|
+
_description: 'The flags, by name, that mark a clinical risk. The lists of every other flag are missing data.',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
quickActions: {
|
|
53
|
+
registerPatientUrl: link(
|
|
54
|
+
'Where the Register patient quick action on ACT home leads.',
|
|
55
|
+
'${openmrsSpaBase}/patient-registration',
|
|
56
|
+
),
|
|
57
|
+
enterProphylaxisUrl: link(
|
|
58
|
+
'Where the Enter prophylaxis quick action on ACT home leads: the fast data entry app, whose forms the distro sets.',
|
|
59
|
+
'${openmrsSpaBase}/forms',
|
|
60
|
+
),
|
|
61
|
+
findPatientUrl: link('Where the Find a patient quick action on ACT home leads.', '${openmrsSpaBase}/search'),
|
|
62
|
+
},
|
|
63
|
+
careCascade: {
|
|
64
|
+
report: {
|
|
65
|
+
_type: Type.String,
|
|
66
|
+
_default: '9c6751ae-65fc-5f25-9aa6-8c65cb1dff68',
|
|
67
|
+
_description:
|
|
68
|
+
'The care cascade report, by uuid or name. The ACT home widget draws its rows, one per step, from their step and patients columns.',
|
|
69
|
+
},
|
|
70
|
+
reportUrl: link("Where the care cascade widget's link to the full report leads.", '${openmrsSpaBase}/reports'),
|
|
71
|
+
steps: {
|
|
72
|
+
_type: Type.Array,
|
|
73
|
+
_elements: { _type: Type.String },
|
|
74
|
+
_default: ['Active', 'Prescribed Prophylaxis', 'Initiated BPG', 'Adherent'],
|
|
75
|
+
_description:
|
|
76
|
+
"The report's steps the widget draws, by their step value, in this order. The report's Oral and BPG rows split Prescribed Prophylaxis, so they are left out by default.",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
registry: {
|
|
80
|
+
report: {
|
|
81
|
+
_type: Type.String,
|
|
82
|
+
_default: 'f1a2b3c4-d5e6-7890-abcd-ef1234567890',
|
|
83
|
+
_description: 'The RHD Patient List report, by uuid or name, whose rows the registry lists.',
|
|
84
|
+
},
|
|
85
|
+
showBpgColumns: {
|
|
86
|
+
_type: Type.Boolean,
|
|
87
|
+
_default: false,
|
|
88
|
+
_description:
|
|
89
|
+
"Show the report's bpg_status and adherence columns and a BPG status filter. Turn on once the report returns them.",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
urgencyBands: {
|
|
93
|
+
_type: Type.Array,
|
|
94
|
+
_elements: {
|
|
95
|
+
label: { _type: Type.String, _description: 'The name of the band.' },
|
|
96
|
+
concept: { _type: Type.ConceptUuid, _description: 'The answer that records this band.' },
|
|
97
|
+
deadlineDays: { _type: Type.Number, _description: 'Days allowed before a patient in this band is overdue.' },
|
|
98
|
+
},
|
|
99
|
+
_default: [
|
|
100
|
+
{ label: '1: Emergent', concept: '1fe15210-4490-58b0-a38c-bb0386e98482', deadlineDays: 1 },
|
|
101
|
+
{ label: '2: Urgent', concept: '33bf504a-15f2-5504-9bdc-ddded0b5eb00', deadlineDays: 60 },
|
|
102
|
+
{ label: '3: Elective', concept: '2666bf97-7400-57c7-b535-7903e22ced34', deadlineDays: 180 },
|
|
103
|
+
],
|
|
104
|
+
_description: 'Urgency bands for the procedural waiting list, with their deadlines, as in ACT 2.0.',
|
|
105
|
+
_validators: [
|
|
106
|
+
validator(
|
|
107
|
+
(bands: Array<{ concept?: string; deadlineDays?: unknown }>) =>
|
|
108
|
+
bands.every((band) => band.concept && typeof band.deadlineDays === 'number'),
|
|
109
|
+
'Every urgency band needs a concept and a number of deadline days',
|
|
110
|
+
),
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export type ActScreen = 'home' | 'registry' | 'waitingList' | 'screenPositive';
|
|
116
|
+
|
|
117
|
+
export interface Config {
|
|
118
|
+
screenPrivileges: Record<ActScreen, string>;
|
|
119
|
+
clinicLocationTags: { cardiac: Array<string>; primaryCare: Array<string> };
|
|
120
|
+
flagLists: {
|
|
121
|
+
namePrefix: string;
|
|
122
|
+
names: Array<string>;
|
|
123
|
+
riskFlags: Array<string>;
|
|
124
|
+
};
|
|
125
|
+
quickActions: { registerPatientUrl: string; enterProphylaxisUrl: string; findPatientUrl: string };
|
|
126
|
+
careCascade: { report: string; reportUrl: string; steps: Array<string> };
|
|
127
|
+
registry: { report: string; showBpgColumns: boolean };
|
|
128
|
+
urgencyBands: Array<{ label: string; concept: string; deadlineDays: number }>;
|
|
129
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
declare module '*.css';
|
|
2
|
+
declare module '*.scss';
|
|
3
|
+
declare module '*.png';
|
|
4
|
+
|
|
5
|
+
declare type SideNavProps = object;
|
|
6
|
+
|
|
7
|
+
declare interface RequireContext {
|
|
8
|
+
keys(): string[];
|
|
9
|
+
(id: string): unknown;
|
|
10
|
+
<T>(id: string): T;
|
|
11
|
+
resolve(id: string): string;
|
|
12
|
+
id: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
declare namespace NodeJS {
|
|
16
|
+
interface Require {
|
|
17
|
+
context(directory: string, useSubdirectories?: boolean, regExp?: RegExp, mode?: string): RequireContext;
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/fetch-all.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { openmrsFetch } from '@openmrs/esm-framework';
|
|
2
|
+
|
|
3
|
+
interface Page<T> {
|
|
4
|
+
results: Array<T>;
|
|
5
|
+
totalCount: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Every page of a REST search, at the page size the server chooses; after the first, the pages load in parallel. */
|
|
9
|
+
export async function fetchAll<T>(url: string) {
|
|
10
|
+
const fetchPage = (startIndex: number) =>
|
|
11
|
+
openmrsFetch<Page<T>>(`${url}&totalCount=true&startIndex=${startIndex}`).then(({ data }) => data);
|
|
12
|
+
const first = await fetchPage(0);
|
|
13
|
+
const pageSize = first.results.length;
|
|
14
|
+
const startIndexes = [];
|
|
15
|
+
for (let start = pageSize; pageSize && start < first.totalCount; start += pageSize) {
|
|
16
|
+
startIndexes.push(start);
|
|
17
|
+
}
|
|
18
|
+
const rest = await Promise.all(startIndexes.map(fetchPage));
|
|
19
|
+
return [first, ...rest].flatMap((page) => page.results);
|
|
20
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
3
|
+
import { openmrsFetch } from '@openmrs/esm-framework';
|
|
4
|
+
import { usePatientFlagGaps } from './flag-gaps.resource';
|
|
5
|
+
|
|
6
|
+
const mockOpenmrsFetch = vi.mocked(openmrsFetch);
|
|
7
|
+
|
|
8
|
+
const gap = {
|
|
9
|
+
encounter: 'encounter-1',
|
|
10
|
+
encounterDatetime: '2026-09-10T09:00:00.000+0000',
|
|
11
|
+
form: { uuid: 'form-uuid', display: 'Procedures and Outcomes' },
|
|
12
|
+
concept: { uuid: 'perfusion-uuid', display: 'Perfusion Issues' },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function respondWith(byUrl: (url: string) => unknown) {
|
|
16
|
+
mockOpenmrsFetch.mockImplementation((url: string) => Promise.resolve({ data: byUrl(url) }) as never);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('usePatientFlagGaps', () => {
|
|
20
|
+
it('looks up the gaps behind each flag the patient has raised now, each once', async () => {
|
|
21
|
+
respondWith((url) =>
|
|
22
|
+
url.includes('/patientflags/patientflag')
|
|
23
|
+
? {
|
|
24
|
+
results: [
|
|
25
|
+
{ voided: false, flag: { uuid: 'perfusion', display: 'RHD perfusion issues not recorded' } },
|
|
26
|
+
{ voided: false, flag: { uuid: 'perfusion', display: 'RHD perfusion issues not recorded' } },
|
|
27
|
+
{ voided: true, flag: { uuid: 'cleared', display: 'RHD site infection not recorded' } },
|
|
28
|
+
],
|
|
29
|
+
}
|
|
30
|
+
: { configured: true, results: [gap] },
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const { result } = renderHook(() => usePatientFlagGaps('patient-uuid'));
|
|
34
|
+
|
|
35
|
+
await waitFor(() => expect(result.current.flagGaps).toHaveLength(1));
|
|
36
|
+
expect(result.current.flagGaps[0]).toEqual({
|
|
37
|
+
flagUuid: 'perfusion',
|
|
38
|
+
flagName: 'RHD perfusion issues not recorded',
|
|
39
|
+
configured: true,
|
|
40
|
+
gaps: [gap],
|
|
41
|
+
});
|
|
42
|
+
const gapUrls = mockOpenmrsFetch.mock.calls.map(([url]) => url).filter((url) => url.includes('/rhdflags/gap'));
|
|
43
|
+
expect(gapUrls).toEqual([expect.stringContaining('patient=patient-uuid&flag=perfusion')]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('looks up only the clicked flag when it is given', async () => {
|
|
47
|
+
mockOpenmrsFetch.mockClear();
|
|
48
|
+
respondWith(() => ({ configured: false, results: [] }));
|
|
49
|
+
|
|
50
|
+
const { result } = renderHook(() => usePatientFlagGaps('patient-2', { uuid: 'death', name: 'RHD death' }));
|
|
51
|
+
|
|
52
|
+
await waitFor(() => expect(result.current.flagGaps).toHaveLength(1));
|
|
53
|
+
expect(result.current.flagGaps[0]).toMatchObject({ flagUuid: 'death', flagName: 'RHD death', configured: false });
|
|
54
|
+
const urls = mockOpenmrsFetch.mock.calls.map(([url]) => url);
|
|
55
|
+
expect(urls.some((url) => url.includes('/patientflags/patientflag'))).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import useSWR from 'swr';
|
|
2
|
+
import { type FetchResponse, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
|
|
3
|
+
|
|
4
|
+
/** One piece of data missing behind a flag, as the rhdflags gap endpoint returns it. */
|
|
5
|
+
export interface FlagGap {
|
|
6
|
+
encounter: string;
|
|
7
|
+
encounterDatetime: string;
|
|
8
|
+
form: { uuid: string; display: string } | null;
|
|
9
|
+
concept: { uuid: string; display: string };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The gaps behind one of the patient's flags; `configured` is false for a flag with no gap query. */
|
|
13
|
+
export interface FlagGaps {
|
|
14
|
+
flagUuid: string;
|
|
15
|
+
flagName: string;
|
|
16
|
+
configured: boolean;
|
|
17
|
+
gaps: Array<FlagGap>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface FlagGapsResponse {
|
|
21
|
+
configured: boolean;
|
|
22
|
+
results: Array<FlagGap>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface PatientFlagsResponse {
|
|
26
|
+
results: Array<{ voided: boolean; flag: { uuid: string; display: string } }>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The representation the patient chart's forms app opens a form with.
|
|
30
|
+
const formRepresentation =
|
|
31
|
+
'custom:(uuid,name,display,encounterType:(uuid,name,viewPrivilege,editPrivilege),version,published,retired,resources:(uuid,name,dataType,valueReference))';
|
|
32
|
+
|
|
33
|
+
async function fetchFlagGaps(patientUuid: string, flagUuid: string, flagName: string): Promise<FlagGaps> {
|
|
34
|
+
const { data } = await openmrsFetch<FlagGapsResponse>(
|
|
35
|
+
`${restBaseUrl}/rhdflags/gap?patient=${patientUuid}&flag=${flagUuid}`,
|
|
36
|
+
);
|
|
37
|
+
return { flagUuid, flagName, configured: data.configured, gaps: data.results };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The patient's flags that are raised now, each once. */
|
|
41
|
+
async function fetchRaisedFlags(patientUuid: string) {
|
|
42
|
+
const { data } = await openmrsFetch<PatientFlagsResponse>(
|
|
43
|
+
`${restBaseUrl}/patientflags/patientflag?patient=${patientUuid}&v=custom:(voided,flag:(uuid,display))`,
|
|
44
|
+
);
|
|
45
|
+
const flags = new Map<string, string>();
|
|
46
|
+
data.results.filter((row) => !row.voided).forEach((row) => flags.set(row.flag.uuid, row.flag.display));
|
|
47
|
+
return [...flags].map(([uuid, name]) => ({ uuid, name }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The gaps behind the clicked flag, or, when the workspace was not told which flag was clicked, behind
|
|
52
|
+
* every flag the patient has raised now.
|
|
53
|
+
*/
|
|
54
|
+
export function usePatientFlagGaps(patientUuid: string, clickedFlag?: { uuid: string; name: string }) {
|
|
55
|
+
const { data, error, isLoading } = useSWR<Array<FlagGaps>, Error>(
|
|
56
|
+
patientUuid ? ['rhd-flag-gaps', patientUuid, clickedFlag?.uuid ?? 'every raised flag'] : null,
|
|
57
|
+
async () => {
|
|
58
|
+
const flags = clickedFlag ? [clickedFlag] : await fetchRaisedFlags(patientUuid);
|
|
59
|
+
return Promise.all(flags.map((flag) => fetchFlagGaps(patientUuid, flag.uuid, flag.name)));
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return { flagGaps: data ?? [], isLoading, error };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function fetchForm(formUuid: string) {
|
|
67
|
+
const response = await openmrsFetch(`${restBaseUrl}/form/${formUuid}?v=${formRepresentation}`);
|
|
68
|
+
return response.data;
|
|
69
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
@use '@carbon/layout';
|
|
2
|
+
@use '@carbon/type';
|
|
3
|
+
|
|
4
|
+
.container {
|
|
5
|
+
padding: layout.$spacing-05;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.message {
|
|
9
|
+
@include type.type-style('body-compact-01');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.flag + .flag {
|
|
13
|
+
margin-top: layout.$spacing-07;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.flagName {
|
|
17
|
+
@include type.type-style('heading-compact-02');
|
|
18
|
+
margin-bottom: layout.$spacing-03;
|
|
19
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import { showSnackbar } from '@openmrs/esm-framework';
|
|
6
|
+
import { fetchForm, type FlagGap, type FlagGaps, usePatientFlagGaps } from './flag-gaps.resource';
|
|
7
|
+
import FlagGapsWorkspace, { daysPending, type FlagActionWorkspaceProps } from './flag-gaps.workspace';
|
|
8
|
+
|
|
9
|
+
vi.mock('./flag-gaps.resource', () => ({
|
|
10
|
+
usePatientFlagGaps: vi.fn(),
|
|
11
|
+
fetchForm: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const mockUsePatientFlagGaps = vi.mocked(usePatientFlagGaps);
|
|
15
|
+
const mockFetchForm = vi.mocked(fetchForm);
|
|
16
|
+
|
|
17
|
+
const workspaceProps: FlagActionWorkspaceProps = {
|
|
18
|
+
patientUuid: 'patient-uuid',
|
|
19
|
+
patientFlagUuid: 'patient-flag-uuid',
|
|
20
|
+
flagUuid: 'flag-uuid',
|
|
21
|
+
flagName: 'RHD perfusion issues not recorded',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const firstGap: FlagGap = {
|
|
25
|
+
encounter: 'encounter-1',
|
|
26
|
+
encounterDatetime: '2026-09-10T09:00:00.000+0000',
|
|
27
|
+
form: { uuid: 'form-uuid', display: 'Procedures and Outcomes' },
|
|
28
|
+
concept: { uuid: 'perfusion-uuid', display: 'Perfusion Issues' },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const secondGap: FlagGap = { ...firstGap, encounter: 'encounter-2', encounterDatetime: '2026-09-20T10:30:00.000+0000' };
|
|
32
|
+
|
|
33
|
+
function showWorkspace(
|
|
34
|
+
launchChildWorkspace = vi.fn(),
|
|
35
|
+
props: Partial<FlagActionWorkspaceProps> | null = workspaceProps,
|
|
36
|
+
) {
|
|
37
|
+
render(
|
|
38
|
+
<FlagGapsWorkspace
|
|
39
|
+
workspaceProps={props}
|
|
40
|
+
windowProps={null}
|
|
41
|
+
groupProps={{ patientUuid: 'patient-uuid' }}
|
|
42
|
+
launchChildWorkspace={launchChildWorkspace}
|
|
43
|
+
closeWorkspace={vi.fn()}
|
|
44
|
+
workspaceName="rhd-flag-gaps-workspace"
|
|
45
|
+
windowName="patient-chart-clinical-forms"
|
|
46
|
+
isRootWorkspace
|
|
47
|
+
showActionMenu={false}
|
|
48
|
+
/>,
|
|
49
|
+
);
|
|
50
|
+
return launchChildWorkspace;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function flagWith(gaps: Array<FlagGap>, overrides: Partial<FlagGaps> = {}): FlagGaps {
|
|
54
|
+
return { flagUuid: 'flag-uuid', flagName: 'RHD perfusion issues not recorded', configured: true, gaps, ...overrides };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function flagsReturned(flagGaps: Array<FlagGaps>, overrides: Partial<ReturnType<typeof usePatientFlagGaps>> = {}) {
|
|
58
|
+
mockUsePatientFlagGaps.mockReturnValue({ flagGaps, isLoading: false, error: undefined, ...overrides });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The clicked flag's gaps, as the lookup returns them for one flag.
|
|
62
|
+
function gapsReturned({
|
|
63
|
+
gaps = [],
|
|
64
|
+
configured = true,
|
|
65
|
+
error,
|
|
66
|
+
}: {
|
|
67
|
+
gaps?: Array<FlagGap>;
|
|
68
|
+
configured?: boolean;
|
|
69
|
+
error?: Error;
|
|
70
|
+
}) {
|
|
71
|
+
flagsReturned(error ? [] : [flagWith(gaps, { configured })], { error });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('flag gaps workspace', () => {
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
77
|
+
vi.setSystemTime(new Date('2026-09-25T12:00:00.000Z'));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
vi.useRealTimers();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('asks for the gaps of the clicked flag for the patient', () => {
|
|
85
|
+
gapsReturned({});
|
|
86
|
+
|
|
87
|
+
showWorkspace();
|
|
88
|
+
|
|
89
|
+
expect(mockUsePatientFlagGaps).toHaveBeenCalledWith('patient-uuid', {
|
|
90
|
+
uuid: 'flag-uuid',
|
|
91
|
+
name: 'RHD perfusion issues not recorded',
|
|
92
|
+
});
|
|
93
|
+
expect(screen.getByText('RHD perfusion issues not recorded')).toBeInTheDocument();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('lists one row per gap with its form, question and days pending', () => {
|
|
97
|
+
gapsReturned({ gaps: [firstGap, secondGap] });
|
|
98
|
+
|
|
99
|
+
showWorkspace();
|
|
100
|
+
|
|
101
|
+
const rows = screen.getAllByRole('row');
|
|
102
|
+
expect(rows).toHaveLength(3);
|
|
103
|
+
expect(rows[1]).toHaveTextContent('Procedures and Outcomes');
|
|
104
|
+
expect(rows[1]).toHaveTextContent('Perfusion Issues');
|
|
105
|
+
expect(rows[1]).toHaveTextContent('15');
|
|
106
|
+
expect(rows[2]).toHaveTextContent('5');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("opens the gap's encounter in the form entry workspace", async () => {
|
|
110
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
111
|
+
const form = { uuid: 'form-uuid', name: 'Procedures and Outcomes' };
|
|
112
|
+
mockFetchForm.mockResolvedValue(form);
|
|
113
|
+
gapsReturned({ gaps: [firstGap, secondGap] });
|
|
114
|
+
|
|
115
|
+
const launchChildWorkspace = showWorkspace();
|
|
116
|
+
await user.click(screen.getAllByRole('button', { name: /open form/i })[1]);
|
|
117
|
+
|
|
118
|
+
expect(mockFetchForm).toHaveBeenCalledWith('form-uuid');
|
|
119
|
+
expect(launchChildWorkspace).toHaveBeenCalledWith('patient-form-entry-workspace', {
|
|
120
|
+
form,
|
|
121
|
+
encounterUuid: 'encounter-2',
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('says so when the form cannot be opened', async () => {
|
|
126
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
127
|
+
mockFetchForm.mockRejectedValue(new Error('forbidden'));
|
|
128
|
+
gapsReturned({ gaps: [firstGap] });
|
|
129
|
+
|
|
130
|
+
const launchChildWorkspace = showWorkspace();
|
|
131
|
+
await user.click(screen.getByRole('button', { name: /open form/i }));
|
|
132
|
+
|
|
133
|
+
expect(launchChildWorkspace).not.toHaveBeenCalled();
|
|
134
|
+
expect(showSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error', subtitle: 'forbidden' }));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('offers no way to open a gap whose form the user may not see', () => {
|
|
138
|
+
gapsReturned({ gaps: [{ ...firstGap, form: null }] });
|
|
139
|
+
|
|
140
|
+
showWorkspace();
|
|
141
|
+
|
|
142
|
+
expect(screen.getAllByRole('row')).toHaveLength(2);
|
|
143
|
+
expect(screen.queryByRole('button', { name: /open form/i })).not.toBeInTheDocument();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('says so when the flag does not list its missing data', () => {
|
|
147
|
+
gapsReturned({ configured: false });
|
|
148
|
+
|
|
149
|
+
showWorkspace();
|
|
150
|
+
|
|
151
|
+
expect(screen.getByText(/does not list its missing data/i)).toBeInTheDocument();
|
|
152
|
+
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('offers a new form when no saved form is waiting to be completed', async () => {
|
|
156
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
157
|
+
gapsReturned({ gaps: [] });
|
|
158
|
+
|
|
159
|
+
const launchChildWorkspace = showWorkspace();
|
|
160
|
+
expect(screen.getByText(/no saved form is waiting to be completed/i)).toBeInTheDocument();
|
|
161
|
+
await user.click(screen.getByRole('button', { name: /open clinical forms/i }));
|
|
162
|
+
|
|
163
|
+
expect(launchChildWorkspace).toHaveBeenCalledWith('clinical-forms-workspace');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("lists the gaps behind each of the patient's flags when not told which flag was clicked", () => {
|
|
167
|
+
const sepsis = { ...firstGap, concept: { uuid: 'sepsis-uuid', display: 'Bacterial Sepsis' } };
|
|
168
|
+
flagsReturned([
|
|
169
|
+
flagWith([firstGap]),
|
|
170
|
+
flagWith([], { flagUuid: 'overdue-uuid', flagName: 'RHD prophylaxis overdue', configured: false }),
|
|
171
|
+
flagWith([sepsis], { flagUuid: 'sepsis-flag-uuid', flagName: 'RHD bacterial sepsis not recorded' }),
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
showWorkspace(vi.fn(), null);
|
|
175
|
+
|
|
176
|
+
expect(mockUsePatientFlagGaps).toHaveBeenCalledWith('patient-uuid', undefined);
|
|
177
|
+
expect(screen.getByText('Missing data')).toBeInTheDocument();
|
|
178
|
+
expect(screen.getByRole('heading', { name: 'RHD perfusion issues not recorded' })).toBeInTheDocument();
|
|
179
|
+
expect(screen.getByRole('heading', { name: 'RHD bacterial sepsis not recorded' })).toBeInTheDocument();
|
|
180
|
+
expect(screen.queryByText('RHD prophylaxis overdue')).not.toBeInTheDocument();
|
|
181
|
+
expect(screen.getAllByRole('table')).toHaveLength(2);
|
|
182
|
+
expect(screen.getByText('Bacterial Sepsis')).toBeInTheDocument();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("says so when none of the patient's flags lists its missing data", () => {
|
|
186
|
+
flagsReturned([flagWith([], { configured: false })]);
|
|
187
|
+
|
|
188
|
+
showWorkspace(vi.fn(), null);
|
|
189
|
+
|
|
190
|
+
expect(screen.getByText(/none of this patient's flags lists its missing data/i)).toBeInTheDocument();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('says so when the gaps cannot be loaded', () => {
|
|
194
|
+
gapsReturned({ error: new Error('Privilege required: View Patient Flags') });
|
|
195
|
+
|
|
196
|
+
showWorkspace();
|
|
197
|
+
|
|
198
|
+
expect(screen.getByText(/could not load the missing data/i)).toBeInTheDocument();
|
|
199
|
+
expect(screen.getByText(/privilege required/i)).toBeInTheDocument();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('daysPending', () => {
|
|
204
|
+
it('counts whole days since the encounter', () => {
|
|
205
|
+
expect(daysPending('2026-09-10T09:00:00.000+0000', new Date('2026-09-25T08:59:00.000Z'))).toBe(14);
|
|
206
|
+
expect(daysPending('2026-09-10T09:00:00.000+0000', new Date('2026-09-25T09:00:00.000Z'))).toBe(15);
|
|
207
|
+
});
|
|
208
|
+
});
|