@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.
Files changed (140) hide show
  1. package/.editorconfig +12 -0
  2. package/.husky/pre-commit +7 -0
  3. package/.husky/pre-push +6 -0
  4. package/.prettierignore +14 -0
  5. package/.turbo.json +18 -0
  6. package/.yarn/plugins/@yarnpkg/plugin-outdated.cjs +35 -0
  7. package/LICENSE +401 -0
  8. package/README.md +115 -0
  9. package/SECURITY.md +12 -0
  10. package/__mocks__/react-i18next.js +50 -0
  11. package/dist/117.js +1 -0
  12. package/dist/117.js.map +1 -0
  13. package/dist/131.css +1 -0
  14. package/dist/131.css.map +1 -0
  15. package/dist/131.js +1 -0
  16. package/dist/131.js.map +1 -0
  17. package/dist/197.js +1 -0
  18. package/dist/197.js.map +1 -0
  19. package/dist/237.js +1 -0
  20. package/dist/237.js.map +1 -0
  21. package/dist/29.js +1 -0
  22. package/dist/29.js.map +1 -0
  23. package/dist/338.css +1 -0
  24. package/dist/338.css.map +1 -0
  25. package/dist/338.js +1 -0
  26. package/dist/338.js.map +1 -0
  27. package/dist/362.js +1 -0
  28. package/dist/362.js.map +1 -0
  29. package/dist/379.js +1 -0
  30. package/dist/379.js.map +1 -0
  31. package/dist/410.js +1 -0
  32. package/dist/410.js.map +1 -0
  33. package/dist/430.css +1 -0
  34. package/dist/430.css.map +1 -0
  35. package/dist/430.js +1 -0
  36. package/dist/430.js.map +1 -0
  37. package/dist/450.css +1 -0
  38. package/dist/450.css.map +1 -0
  39. package/dist/450.js +1 -0
  40. package/dist/450.js.map +1 -0
  41. package/dist/517.js +1 -0
  42. package/dist/517.js.map +1 -0
  43. package/dist/538.js +1 -0
  44. package/dist/538.js.map +1 -0
  45. package/dist/578.js +1 -0
  46. package/dist/578.js.map +1 -0
  47. package/dist/638.js +1 -0
  48. package/dist/638.js.map +1 -0
  49. package/dist/708.css +1 -0
  50. package/dist/708.css.map +1 -0
  51. package/dist/708.js +1 -0
  52. package/dist/708.js.map +1 -0
  53. package/dist/749.js +1 -0
  54. package/dist/749.js.map +1 -0
  55. package/dist/823.css +1 -0
  56. package/dist/823.css.map +1 -0
  57. package/dist/823.js +1 -0
  58. package/dist/823.js.map +1 -0
  59. package/dist/898.js +1 -0
  60. package/dist/898.js.map +1 -0
  61. package/dist/943.js +1 -0
  62. package/dist/943.js.map +1 -0
  63. package/dist/957.css +1 -0
  64. package/dist/957.css.map +1 -0
  65. package/dist/957.js +43 -0
  66. package/dist/957.js.map +1 -0
  67. package/dist/977.js +1 -0
  68. package/dist/977.js.map +1 -0
  69. package/dist/98.js +1 -0
  70. package/dist/98.js.map +1 -0
  71. package/dist/997.js +1 -0
  72. package/dist/997.js.map +1 -0
  73. package/dist/999.js +6 -0
  74. package/dist/999.js.map +1 -0
  75. package/dist/mherman22-esm-act-app.js +3 -0
  76. package/dist/mherman22-esm-act-app.js.buildmanifest.json +781 -0
  77. package/dist/mherman22-esm-act-app.js.map +1 -0
  78. package/dist/routes.json +1 -0
  79. package/eslint.config.mjs +20 -0
  80. package/package.json +99 -0
  81. package/prettier.config.js +8 -0
  82. package/rspack.config.js +1 -0
  83. package/src/access/act-dashboard-link.component.tsx +16 -0
  84. package/src/access/screen-access.component.tsx +15 -0
  85. package/src/access/screen-access.test.tsx +81 -0
  86. package/src/access/sign-in.test-helper.ts +24 -0
  87. package/src/act-home/act-home-card.component.tsx +27 -0
  88. package/src/act-home/act-home-card.scss +25 -0
  89. package/src/act-home/act-home-dashboard-link.component.tsx +7 -0
  90. package/src/act-home/act-home-dashboard-link.test.tsx +32 -0
  91. package/src/act-home/act-home-dashboard.component.tsx +30 -0
  92. package/src/act-home/act-home-dashboard.test.tsx +43 -0
  93. package/src/act-home/act-home.meta.ts +8 -0
  94. package/src/act-home/act-home.scss +22 -0
  95. package/src/act-home/care-cascade.component.tsx +77 -0
  96. package/src/act-home/care-cascade.scss +42 -0
  97. package/src/act-home/care-cascade.test.tsx +128 -0
  98. package/src/act-home/quick-actions.component.tsx +32 -0
  99. package/src/act-home/quick-actions.scss +32 -0
  100. package/src/act-home/quick-actions.test.tsx +51 -0
  101. package/src/act-home/worklist-tiles.component.tsx +69 -0
  102. package/src/act-home/worklist-tiles.scss +50 -0
  103. package/src/act-home/worklist-tiles.test.tsx +133 -0
  104. package/src/config-schema.test.ts +83 -0
  105. package/src/config-schema.ts +129 -0
  106. package/src/declarations.d.ts +19 -0
  107. package/src/fetch-all.ts +20 -0
  108. package/src/flag-gaps/flag-gaps.resource.test.ts +57 -0
  109. package/src/flag-gaps/flag-gaps.resource.ts +69 -0
  110. package/src/flag-gaps/flag-gaps.scss +19 -0
  111. package/src/flag-gaps/flag-gaps.workspace.test.tsx +208 -0
  112. package/src/flag-gaps/flag-gaps.workspace.tsx +149 -0
  113. package/src/index.ts +33 -0
  114. package/src/registry/csv.ts +16 -0
  115. package/src/registry/registry-bpg.test.tsx +84 -0
  116. package/src/registry/registry-csv.test.tsx +100 -0
  117. package/src/registry/registry-dashboard-link.component.tsx +7 -0
  118. package/src/registry/registry-dashboard-link.test.tsx +32 -0
  119. package/src/registry/registry-filters.test.tsx +218 -0
  120. package/src/registry/registry-filters.ts +81 -0
  121. package/src/registry/registry-flags.test.tsx +67 -0
  122. package/src/registry/registry.component.tsx +255 -0
  123. package/src/registry/registry.fixture.ts +14 -0
  124. package/src/registry/registry.meta.ts +6 -0
  125. package/src/registry/registry.scss +41 -0
  126. package/src/registry/registry.test.tsx +147 -0
  127. package/src/reports/report-dataset.resource.test.tsx +189 -0
  128. package/src/reports/report-dataset.resource.ts +51 -0
  129. package/src/reports/report-date.test.ts +18 -0
  130. package/src/reports/report-date.ts +12 -0
  131. package/src/rhd-flags/rhd-flag-lists.resource.test.tsx +240 -0
  132. package/src/rhd-flags/rhd-flag-lists.resource.ts +88 -0
  133. package/src/routes.json +66 -0
  134. package/src/routes.test.ts +32 -0
  135. package/tools/i18next-parser.config.js +89 -0
  136. package/tools/setup-tests.ts +5 -0
  137. package/tools/update-openmrs-deps.mjs +42 -0
  138. package/translations/en.json +52 -0
  139. package/tsconfig.json +24 -0
  140. package/vitest.config.ts +21 -0
@@ -0,0 +1,189 @@
1
+ import React from 'react';
2
+ import { SWRConfig } from 'swr';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { renderHook, waitFor } from '@testing-library/react';
5
+ import { openmrsFetch } from '@openmrs/esm-framework';
6
+ import routes from '../routes.json';
7
+ import { useReportDataset } from './report-dataset.resource';
8
+
9
+ const mockOpenmrsFetch = vi.mocked(openmrsFetch);
10
+
11
+ const waitingListUuid = '5b0f1c2e-9d3a-4c1b-8f6e-2a7d9e4b3c10';
12
+ const columns = [
13
+ { name: 'patient_uuid', label: 'Patient', datatype: 'java.lang.String' },
14
+ { name: 'urgency', label: 'Urgency', datatype: 'java.lang.String' },
15
+ ];
16
+ const rows = [{ patient_uuid: 'amina', urgency: '2: Urgent' }];
17
+
18
+ function evaluated(datasetRows: Array<Record<string, unknown>>, datasetColumns = columns) {
19
+ return { uuid: waitingListUuid, dataSets: [{ metadata: { columns: datasetColumns }, rows: datasetRows }] };
20
+ }
21
+
22
+ function respond(byUrl: (url: string) => unknown) {
23
+ mockOpenmrsFetch.mockImplementation(((url: string) => {
24
+ const data = byUrl(url);
25
+ return data instanceof Error ? Promise.reject(data) : Promise.resolve({ data });
26
+ }) as never);
27
+ }
28
+
29
+ function renderDataset(report: string | null, params?: Record<string, string>, cache = new Map()) {
30
+ return renderHook(() => useReportDataset(report, params), {
31
+ wrapper: ({ children }) => <SWRConfig value={{ provider: () => cache, dedupingInterval: 0 }}>{children}</SWRConfig>,
32
+ });
33
+ }
34
+
35
+ describe('useReportDataset', () => {
36
+ it("evaluates the report with its parameters and returns the dataset's columns and rows", async () => {
37
+ respond(() => evaluated(rows));
38
+
39
+ const { result } = renderDataset(waitingListUuid, { startDate: '2026-01-01', clinic: 'Kitgum & Lira' });
40
+
41
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
42
+ expect(result.current).toMatchObject({ columns, rows, error: undefined });
43
+ const [url] = mockOpenmrsFetch.mock.calls.map(([url]) => url);
44
+ expect(url).toContain(`/reportingrest/reportdata/${waitingListUuid}?`);
45
+ expect(new URL(url, 'http://host').searchParams.get('clinic')).toBe('Kitgum & Lira');
46
+ expect(new URL(url, 'http://host').searchParams.get('startDate')).toBe('2026-01-01');
47
+ });
48
+
49
+ it('finds a report by its exact name among the reports whose names contain it', async () => {
50
+ respond((url) =>
51
+ url.includes('/reportDefinition')
52
+ ? {
53
+ results: [
54
+ { uuid: 'other-uuid', name: 'Procedural Waiting List (archived)' },
55
+ { uuid: waitingListUuid, name: 'Procedural Waiting List' },
56
+ ],
57
+ }
58
+ : evaluated(rows),
59
+ );
60
+
61
+ const { result } = renderDataset('Procedural Waiting List');
62
+
63
+ await waitFor(() => expect(result.current.rows).toEqual(rows));
64
+ const urls = mockOpenmrsFetch.mock.calls.map(([url]) => url);
65
+ expect(urls[0]).toContain('/reportingrest/reportDefinition?q=Procedural%20Waiting%20List');
66
+ expect(urls[1]).toContain(`/reportingrest/reportdata/${waitingListUuid}`);
67
+ });
68
+
69
+ it('finds the exact name on a later page of the search', async () => {
70
+ const hits = [
71
+ ...Array.from({ length: 50 }, (_, i) => ({ uuid: `archived-${i}`, name: `Procedural Waiting List ${i}` })),
72
+ { uuid: waitingListUuid, name: 'Procedural Waiting List' },
73
+ ];
74
+ respond((url) => {
75
+ if (!url.includes('/reportDefinition')) {
76
+ return evaluated(rows);
77
+ }
78
+ const start = Number(new URL(url, 'http://host').searchParams.get('startIndex'));
79
+ return { results: hits.slice(start, start + 50), totalCount: hits.length };
80
+ });
81
+
82
+ const { result } = renderDataset('Procedural Waiting List');
83
+
84
+ await waitFor(() => expect(result.current.rows).toEqual(rows));
85
+ });
86
+
87
+ it('says so when no report has the name', async () => {
88
+ respond(() => ({ results: [{ uuid: 'other-uuid', name: 'Procedural Waiting List (archived)' }] }));
89
+
90
+ const { result } = renderDataset('Procedural Waiting List');
91
+
92
+ await waitFor(() => expect(result.current.error?.message).toBe('No report is named "Procedural Waiting List"'));
93
+ expect(mockOpenmrsFetch.mock.calls.some(([url]) => url.includes('/reportdata/'))).toBe(false);
94
+ });
95
+
96
+ it('returns no columns and no rows for an empty dataset, as the server sends none', async () => {
97
+ respond(() => evaluated([], []));
98
+
99
+ const { result } = renderDataset(waitingListUuid);
100
+
101
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
102
+ expect(result.current).toMatchObject({ columns: [], rows: [], error: undefined });
103
+ });
104
+
105
+ it('returns the server error', async () => {
106
+ const serverError = Object.assign(new Error('Server responded with 500'), { response: { status: 500 } });
107
+ respond(() => serverError);
108
+
109
+ const { result } = renderDataset(waitingListUuid, { startDate: '2026-01-01' });
110
+
111
+ await waitFor(() => expect(result.current.error).toBe(serverError));
112
+ expect(result.current).toMatchObject({ columns: [], rows: [], isLoading: false });
113
+ });
114
+
115
+ it('does not evaluate a failing report again', async () => {
116
+ const serverError = Object.assign(new Error('Server responded with 500'), { response: { status: 500 } });
117
+ respond(() => serverError);
118
+
119
+ const { result } = renderHook(() => useReportDataset(waitingListUuid, { startDate: '' }), {
120
+ wrapper: ({ children }) => (
121
+ <SWRConfig value={{ provider: () => new Map(), errorRetryInterval: 1, errorRetryCount: 3 }}>
122
+ {children}
123
+ </SWRConfig>
124
+ ),
125
+ });
126
+
127
+ await waitFor(() => expect(result.current.error).toBe(serverError));
128
+ await new Promise((resolve) => setTimeout(resolve, 100));
129
+ expect(mockOpenmrsFetch).toHaveBeenCalledTimes(1);
130
+ });
131
+
132
+ it('evaluates the report once for the same parameters given in another order', async () => {
133
+ respond(() => evaluated(rows));
134
+
135
+ const { result } = renderHook(
136
+ () => [
137
+ useReportDataset(waitingListUuid, { startDate: '2026-01-01', endDate: '2026-12-31' }),
138
+ useReportDataset(waitingListUuid, { endDate: '2026-12-31', startDate: '2026-01-01' }),
139
+ ],
140
+ { wrapper: ({ children }) => <SWRConfig value={{ provider: () => new Map() }}>{children}</SWRConfig> },
141
+ );
142
+
143
+ await waitFor(() => expect(result.current.every((dataset) => dataset.rows.length === 1)).toBe(true));
144
+ expect(mockOpenmrsFetch).toHaveBeenCalledTimes(1);
145
+ });
146
+
147
+ it('evaluates the report again when its parameters change', async () => {
148
+ respond(() => evaluated(rows));
149
+
150
+ const { result, rerender } = renderHook(({ params }) => useReportDataset(waitingListUuid, params), {
151
+ initialProps: { params: { startDate: '2026-01-01' } },
152
+ wrapper: ({ children }) => <SWRConfig value={{ provider: () => new Map() }}>{children}</SWRConfig>,
153
+ });
154
+ await waitFor(() => expect(result.current.rows).toEqual(rows));
155
+ rerender({ params: { startDate: '2026-06-01' } });
156
+
157
+ await waitFor(() => expect(mockOpenmrsFetch).toHaveBeenCalledTimes(2));
158
+ expect(new URL(mockOpenmrsFetch.mock.calls[1][0], 'http://host').searchParams.get('startDate')).toBe('2026-06-01');
159
+ });
160
+
161
+ it('shows the cached rows when a screen mounts again, then evaluates the report again', async () => {
162
+ respond(() => evaluated(rows));
163
+ const cache = new Map();
164
+ const { result: firstMount, unmount } = renderDataset(waitingListUuid, {}, cache);
165
+ await waitFor(() => expect(firstMount.current.rows).toEqual(rows));
166
+ unmount();
167
+ const updatedRows = [{ patient_uuid: 'amina', urgency: '1: Emergency' }];
168
+ respond(() => evaluated(updatedRows));
169
+
170
+ const { result: secondMount } = renderDataset(waitingListUuid, {}, cache);
171
+
172
+ expect(secondMount.current).toMatchObject({ rows, isLoading: false });
173
+ await waitFor(() => expect(secondMount.current.rows).toEqual(updatedRows));
174
+ expect(mockOpenmrsFetch).toHaveBeenCalledTimes(2);
175
+ });
176
+
177
+ it('fetches nothing without a report', () => {
178
+ const { result } = renderDataset(null);
179
+
180
+ expect(result.current).toMatchObject({ columns: [], rows: [], isLoading: false });
181
+ expect(mockOpenmrsFetch).not.toHaveBeenCalled();
182
+ });
183
+ });
184
+
185
+ describe('routes.json', () => {
186
+ it('declares the reporting REST module the reports are evaluated through', () => {
187
+ expect(routes.backendDependencies).toHaveProperty('reportingrest');
188
+ });
189
+ });
@@ -0,0 +1,51 @@
1
+ import useSWR from 'swr';
2
+ import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
3
+ import { fetchAll } from '../fetch-all';
4
+
5
+ export interface ReportColumn {
6
+ name: string;
7
+ label: string;
8
+ datatype: string;
9
+ }
10
+
11
+ export type ReportRow = Record<string, unknown>;
12
+
13
+ interface EvaluatedReport {
14
+ dataSets: Array<{ metadata: { columns: Array<ReportColumn> }; rows: Array<ReportRow> }>;
15
+ }
16
+
17
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
18
+
19
+ /** reportDefinition?q matches names containing the text, so the exact name is picked from its hits. */
20
+ async function findReportUuid(name: string) {
21
+ const definitions = await fetchAll<{ uuid: string; name: string }>(
22
+ `${restBaseUrl}/reportingrest/reportDefinition?q=${encodeURIComponent(name)}&v=custom:(uuid,name)`,
23
+ );
24
+ const report = definitions.find((definition) => definition.name === name);
25
+ if (!report) {
26
+ throw new Error(`No report is named "${name}"`);
27
+ }
28
+ return report.uuid;
29
+ }
30
+
31
+ async function evaluateReport(reportUuidOrName: string, params: Record<string, string>) {
32
+ const uuid = uuidPattern.test(reportUuidOrName) ? reportUuidOrName : await findReportUuid(reportUuidOrName);
33
+ const { data } = await openmrsFetch<EvaluatedReport>(
34
+ `${restBaseUrl}/reportingrest/reportdata/${uuid}?${new URLSearchParams(params)}`,
35
+ );
36
+ return data.dataSets[0];
37
+ }
38
+
39
+ /**
40
+ * Evaluates a report, given by uuid or by name, and returns its first dataset. A failure is not retried.
41
+ * The server sends `columns` only with rows, so an empty dataset has none and a screen defines its own headers.
42
+ */
43
+ export function useReportDataset(reportUuidOrName: string | null, params: Record<string, string> = {}) {
44
+ const { data, error, isLoading } = useSWR(
45
+ reportUuidOrName ? ['rhd-report-dataset', reportUuidOrName, params] : null,
46
+ () => evaluateReport(reportUuidOrName, params),
47
+ { shouldRetryOnError: false },
48
+ );
49
+
50
+ return { columns: data?.metadata.columns ?? [], rows: data?.rows ?? [], isLoading, error };
51
+ }
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseReportDate } from './report-date';
3
+
4
+ describe('parseReportDate', () => {
5
+ it('reads an ISO string as its calendar day, whatever its offset', () => {
6
+ expect(parseReportDate('2026-10-15T00:00:00.000+0000')).toEqual(new Date(2026, 9, 15));
7
+ expect(parseReportDate('2026-10-15T23:00:00.000-0500')).toEqual(new Date(2026, 9, 15));
8
+ });
9
+
10
+ it('reads a Java-style array, whose month is 1-based', () => {
11
+ expect(parseReportDate([2026, 5, 1, 9, 0])).toEqual(new Date(2026, 4, 1));
12
+ });
13
+
14
+ it('gives null for an empty or unreadable value', () => {
15
+ expect(parseReportDate(null)).toBeNull();
16
+ expect(parseReportDate('soon')).toBeNull();
17
+ });
18
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * A date column's value as reporting REST sends it: an ISO string, or a Java-style array with a 1-based
3
+ * month. The date is read without its time or offset, so it shows the day the report recorded.
4
+ */
5
+ export function parseReportDate(value: unknown): Date | null {
6
+ if (Array.isArray(value) && value.length >= 3) {
7
+ const [year, month, day] = value.map(Number);
8
+ return new Date(year, month - 1, day);
9
+ }
10
+ const match = typeof value === 'string' ? value.match(/^(\d{4})-(\d{2})-(\d{2})/) : null;
11
+ return match ? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])) : null;
12
+ }
@@ -0,0 +1,240 @@
1
+ import React from 'react';
2
+ import { SWRConfig } from 'swr';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { renderHook, waitFor } from '@testing-library/react';
5
+ import { getDefaultsFromConfigSchema, openmrsFetch, useConfig } from '@openmrs/esm-framework';
6
+ import { type Config, configSchema } from '../config-schema';
7
+ import { listsForPatient, type RhdFlagList, useRhdFlagLists } from './rhd-flag-lists.resource';
8
+
9
+ const mockOpenmrsFetch = vi.mocked(openmrsFetch);
10
+ const mockUseConfig = vi.mocked(useConfig<Config>);
11
+
12
+ const cohorts = [
13
+ { uuid: 'overdue', name: 'RHD prophylaxis overdue' },
14
+ { uuid: 'inr', name: 'RHD INR target missing' },
15
+ { uuid: 'new', name: 'RHD delivery outcome overdue' },
16
+ { uuid: 'other', name: 'HIV viral load due' },
17
+ { uuid: 'hand-made', name: 'Clinic RHD review' },
18
+ { uuid: 'deleted', name: 'RHD deleted flag', voided: true },
19
+ ];
20
+
21
+ const members: Record<string, Array<{ patient: { uuid: string }; voided: boolean }>> = {
22
+ overdue: [
23
+ { patient: { uuid: 'amina' }, voided: false },
24
+ { patient: { uuid: 'peter' }, voided: false },
25
+ { patient: { uuid: 'voided' }, voided: true },
26
+ ],
27
+ inr: [{ patient: { uuid: 'peter' }, voided: false }],
28
+ new: [],
29
+ other: [{ patient: { uuid: 'amina' }, voided: false }],
30
+ };
31
+
32
+ function page<T>(results: Array<T>, url: string, pageSize = 50) {
33
+ const params = new URL(url, 'http://host').searchParams;
34
+ const start = Number(params.get('startIndex'));
35
+ const size = Math.min(pageSize, Number(params.get('limit') ?? pageSize));
36
+ return { results: results.slice(start, start + size), totalCount: results.length };
37
+ }
38
+
39
+ function respond({ pageSize = 50 } = {}) {
40
+ mockOpenmrsFetch.mockImplementation(((url: string) => {
41
+ const params = new URL(url, 'http://host').searchParams;
42
+ if (url.includes('/cohortm/cohortmember')) {
43
+ return Promise.resolve({ data: page(members[params.get('cohort')], url, pageSize) });
44
+ }
45
+ const q = params.get('q').toLowerCase();
46
+ return Promise.resolve({
47
+ data: page(
48
+ cohorts.filter((cohort) => cohort.name.toLowerCase().includes(q)),
49
+ url,
50
+ pageSize,
51
+ ),
52
+ });
53
+ }) as never);
54
+ }
55
+
56
+ function renderLists(cache = new Map(), options: { withMembers?: boolean } = {}) {
57
+ return renderHook(() => useRhdFlagLists(options), {
58
+ wrapper: ({ children }) => <SWRConfig value={{ provider: () => cache, dedupingInterval: 0 }}>{children}</SWRConfig>,
59
+ });
60
+ }
61
+
62
+ function useConfigWith(flagLists: Partial<Config['flagLists']> = {}) {
63
+ const defaults = getDefaultsFromConfigSchema(configSchema) as Config;
64
+ mockUseConfig.mockReturnValue({ ...defaults, flagLists: { ...defaults.flagLists, ...flagLists } });
65
+ }
66
+
67
+ function summary(lists: Array<RhdFlagList>) {
68
+ return lists.map(({ flagName, priority, memberCount, memberUuids, cohortUuid }) => ({
69
+ flagName,
70
+ priority,
71
+ memberCount,
72
+ members: memberUuids && [...memberUuids],
73
+ cohortUuid,
74
+ }));
75
+ }
76
+
77
+ describe('useRhdFlagLists', () => {
78
+ beforeEach(() => useConfigWith());
79
+
80
+ it('lists each list whose name has the prefix, with its current members and priority', async () => {
81
+ respond();
82
+
83
+ const { result } = renderLists(new Map(), { withMembers: true });
84
+
85
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
86
+ expect(result.current.error).toBeUndefined();
87
+ expect(summary(result.current.lists)).toEqual([
88
+ {
89
+ flagName: 'RHD prophylaxis overdue',
90
+ priority: 'risk',
91
+ memberCount: 2,
92
+ members: ['amina', 'peter'],
93
+ cohortUuid: 'overdue',
94
+ },
95
+ {
96
+ flagName: 'RHD INR target missing',
97
+ priority: 'dataQuality',
98
+ memberCount: 1,
99
+ members: ['peter'],
100
+ cohortUuid: 'inr',
101
+ },
102
+ {
103
+ flagName: 'RHD delivery outcome overdue',
104
+ priority: 'dataQuality',
105
+ memberCount: 0,
106
+ members: [],
107
+ cohortUuid: 'new',
108
+ },
109
+ ]);
110
+ });
111
+
112
+ it('counts each list with one small request when the members are not asked for', async () => {
113
+ respond();
114
+
115
+ const { result } = renderLists();
116
+
117
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
118
+ expect(
119
+ summary(result.current.lists).map(({ flagName, memberCount, members }) => [flagName, memberCount, members]),
120
+ ).toEqual([
121
+ ['RHD prophylaxis overdue', 3, undefined],
122
+ ['RHD INR target missing', 1, undefined],
123
+ ['RHD delivery outcome overdue', 0, undefined],
124
+ ]);
125
+ const memberUrls = mockOpenmrsFetch.mock.calls.map(([url]) => url).filter((url) => url.includes('/cohortm/'));
126
+ expect(memberUrls).toHaveLength(3);
127
+ expect(memberUrls.every((url) => url.includes('limit=1'))).toBe(true);
128
+ });
129
+
130
+ it('lists only the configured flags when names is set, with 0 for a flag that has no list yet', async () => {
131
+ useConfigWith({ names: ['HIV viral load due', 'RHD site infection not recorded', 'RHD delivery outcome'] });
132
+ respond();
133
+
134
+ const { result } = renderLists();
135
+
136
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
137
+ expect(
138
+ summary(result.current.lists).map(({ flagName, memberCount, cohortUuid }) => [flagName, memberCount, cohortUuid]),
139
+ ).toEqual([
140
+ ['HIV viral load due', 1, 'other'],
141
+ ['RHD site infection not recorded', 0, null],
142
+ ['RHD delivery outcome', 0, null],
143
+ ]);
144
+ });
145
+
146
+ it('reads every page, at the page size the server chooses', async () => {
147
+ respond({ pageSize: 1 });
148
+
149
+ const { result } = renderLists(new Map(), { withMembers: true });
150
+
151
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
152
+ expect(summary(result.current.lists).map(({ flagName, memberCount }) => [flagName, memberCount])).toEqual([
153
+ ['RHD prophylaxis overdue', 2],
154
+ ['RHD INR target missing', 1],
155
+ ['RHD delivery outcome overdue', 0],
156
+ ]);
157
+ expect(mockOpenmrsFetch.mock.calls.some(([url]) => url.includes('limit='))).toBe(false);
158
+ // One page each: 5 cohorts match the search; the lists' member rows take 3, 1 and 1 (an empty list is one page).
159
+ expect(mockOpenmrsFetch).toHaveBeenCalledTimes(5 + 3 + 1 + 1);
160
+ });
161
+
162
+ it('keeps the lists it has when a screen mounts again', async () => {
163
+ respond();
164
+ const cache = new Map();
165
+
166
+ const { result: firstMount, unmount } = renderLists(cache);
167
+ await waitFor(() => expect(firstMount.current.lists).toHaveLength(3));
168
+ const requests = mockOpenmrsFetch.mock.calls.length;
169
+ unmount();
170
+ const { result: secondMount } = renderLists(cache);
171
+
172
+ expect(secondMount.current.lists).toHaveLength(3);
173
+ await new Promise((resolve) => setTimeout(resolve, 20));
174
+ expect(mockOpenmrsFetch).toHaveBeenCalledTimes(requests);
175
+ });
176
+
177
+ it('fetches the members for a screen that asks for them after one that counted', async () => {
178
+ respond();
179
+ const cache = new Map();
180
+
181
+ const { result: counted } = renderLists(cache);
182
+ await waitFor(() => expect(counted.current.lists).toHaveLength(3));
183
+ const { result: withMembers } = renderLists(cache, { withMembers: true });
184
+
185
+ await waitFor(() => expect(withMembers.current.lists[0]?.memberUuids).toEqual(new Set(['amina', 'peter'])));
186
+ });
187
+
188
+ it('stops reading members when a first page is empty though the server reports a total', async () => {
189
+ mockOpenmrsFetch.mockImplementation(((url: string) =>
190
+ Promise.resolve({
191
+ data: url.includes('/cohortm/cohortmember')
192
+ ? { results: [], totalCount: 5 }
193
+ : { results: cohorts.slice(0, 1), totalCount: 1 },
194
+ })) as never);
195
+
196
+ const { result } = renderLists(new Map(), { withMembers: true });
197
+
198
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
199
+ expect(summary(result.current.lists).map(({ memberCount, members }) => [memberCount, members])).toEqual([[0, []]]);
200
+ });
201
+
202
+ it('is loading until the lists arrive', () => {
203
+ mockOpenmrsFetch.mockReturnValue(new Promise(() => {}) as never);
204
+
205
+ const { result } = renderLists();
206
+
207
+ expect(result.current.isLoading).toBe(true);
208
+ expect(result.current.lists).toEqual([]);
209
+ });
210
+
211
+ it('reports a failed request', async () => {
212
+ const serverError = Object.assign(new Error('Server responded with 500'), { response: { status: 500 } });
213
+ respond();
214
+ mockOpenmrsFetch.mockImplementation(((url: string) =>
215
+ url.includes('/cohortm/cohortmember')
216
+ ? Promise.reject(serverError)
217
+ : Promise.resolve({ data: { results: cohorts, totalCount: cohorts.length } })) as never);
218
+
219
+ const { result } = renderLists();
220
+
221
+ await waitFor(() => expect(result.current.error).toBe(serverError));
222
+ expect(result.current.lists).toEqual([]);
223
+ });
224
+ });
225
+
226
+ describe('listsForPatient', () => {
227
+ it('names the lists the patient is on', () => {
228
+ const list = (flagName: string, memberUuids: Array<string>): RhdFlagList => ({
229
+ cohortUuid: flagName,
230
+ flagName,
231
+ priority: 'dataQuality',
232
+ memberCount: memberUuids.length,
233
+ memberUuids: new Set(memberUuids),
234
+ });
235
+ const lists = [list('RHD a', ['amina', 'peter']), list('RHD b', ['peter'])];
236
+
237
+ expect(listsForPatient(lists, 'amina').map((l) => l.flagName)).toEqual(['RHD a']);
238
+ expect(listsForPatient(lists, 'nobody')).toEqual([]);
239
+ });
240
+ });
@@ -0,0 +1,88 @@
1
+ import useSWR from 'swr';
2
+ import { openmrsFetch, restBaseUrl, useConfig } from '@openmrs/esm-framework';
3
+ import { type Config } from '../config-schema';
4
+ import { fetchAll } from '../fetch-all';
5
+
6
+ /** The patient list rhdflags keeps for one flag, with the patients on it now. */
7
+ export interface RhdFlagList {
8
+ cohortUuid: string | null;
9
+ flagName: string;
10
+ priority: 'risk' | 'dataQuality';
11
+ memberCount: number;
12
+ /** Present when the lists were asked for with their members. */
13
+ memberUuids?: ReadonlySet<string>;
14
+ }
15
+
16
+ /** Lists are read as cohorts: the flags themselves are readable only with privileges that run SQL. */
17
+ function searchLists(text: string) {
18
+ return fetchAll<{ uuid: string; name: string; voided: boolean }>(
19
+ `${restBaseUrl}/cohort?q=${encodeURIComponent(text)}&v=custom:(uuid,name,voided)`,
20
+ ).then((lists) => lists.filter((list) => !list.voided));
21
+ }
22
+
23
+ /** Counts memberships that have not ended; the count also takes in memberships voided along with their patient. */
24
+ async function countMembers(cohortUuid: string) {
25
+ const { data } = await openmrsFetch<{ totalCount: number }>(
26
+ `${restBaseUrl}/cohortm/cohortmember?cohort=${cohortUuid}&v=custom:(uuid)&limit=1&totalCount=true`,
27
+ );
28
+ return data.totalCount;
29
+ }
30
+
31
+ /** The cohort module returns only memberships that have not ended. */
32
+ async function fetchMemberUuids(cohortUuid: string) {
33
+ const members = await fetchAll<{ patient: { uuid: string }; voided: boolean }>(
34
+ `${restBaseUrl}/cohortm/cohortmember?cohort=${cohortUuid}&v=custom:(patient:(uuid),voided)`,
35
+ );
36
+ return new Set(members.filter((member) => !member.voided).map((member) => member.patient.uuid));
37
+ }
38
+
39
+ /** Whether the flag is one of the configured RHD flags: named in names, or else starting with namePrefix. */
40
+ export function isListedFlag({ names, namePrefix }: Config['flagLists'], flagName: string) {
41
+ return names.length ? names.includes(flagName) : flagName.startsWith(namePrefix);
42
+ }
43
+
44
+ export function flagPriority({ riskFlags }: Config['flagLists'], flagName: string): RhdFlagList['priority'] {
45
+ return riskFlags.includes(flagName) ? 'risk' : 'dataQuality';
46
+ }
47
+
48
+ async function findLists(flagLists: Config['flagLists']) {
49
+ const { names, namePrefix } = flagLists;
50
+ if (names.length) {
51
+ const found = await Promise.all(names.map(async (name) => (await searchLists(name)).find((l) => l.name === name)));
52
+ return names.map((name, i) => ({ name, uuid: found[i]?.uuid ?? null }));
53
+ }
54
+ return (await searchLists(namePrefix)).filter((list) => isListedFlag(flagLists, list.name));
55
+ }
56
+
57
+ /**
58
+ * The configured RHD flag lists, each with its current patient count, and its patients when `withMembers` is set.
59
+ * The cohort module loads a whole list on the server for every page it returns, so ask for members only where needed.
60
+ */
61
+ export function useRhdFlagLists({ withMembers = false } = {}) {
62
+ const { flagLists } = useConfig<Config>();
63
+ const { data, error, isLoading } = useSWR<Array<RhdFlagList>, Error>(
64
+ ['rhd-flag-lists', flagLists, withMembers],
65
+ async () => {
66
+ const lists = await findLists(flagLists);
67
+ return Promise.all(
68
+ lists.map(async (list): Promise<RhdFlagList> => {
69
+ const common = { cohortUuid: list.uuid, flagName: list.name, priority: flagPriority(flagLists, list.name) };
70
+ if (!withMembers) {
71
+ return { ...common, memberCount: list.uuid ? await countMembers(list.uuid) : 0 };
72
+ }
73
+ const memberUuids = list.uuid ? await fetchMemberUuids(list.uuid) : new Set<string>();
74
+ return { ...common, memberCount: memberUuids.size, memberUuids };
75
+ }),
76
+ );
77
+ },
78
+ // rhdflags updates the lists daily, so a screen mounting again reuses the cached lists.
79
+ { revalidateIfStale: false },
80
+ );
81
+
82
+ return { lists: data ?? [], isLoading, error };
83
+ }
84
+
85
+ /** The lists the patient is on now, among lists fetched with their members. */
86
+ export function listsForPatient(lists: Array<RhdFlagList>, patientUuid: string) {
87
+ return lists.filter((list) => list.memberUuids?.has(patientUuid));
88
+ }
@@ -0,0 +1,66 @@
1
+ {
2
+ "$schema": "https://json.openmrs.org/routes.schema.json",
3
+ "backendDependencies": {
4
+ "webservices.rest": ">=2.40.0",
5
+ "rhdflags": ">=1.0.0-SNAPSHOT",
6
+ "cohort": ">=3.7.3",
7
+ "reportingrest": ">=2.0.0"
8
+ },
9
+ "extensions": [
10
+ {
11
+ "name": "act-home-dashboard-link",
12
+ "slot": "homepage-dashboard-slot",
13
+ "component": "actHomeDashboardLink",
14
+ "meta": {
15
+ "name": "act-home",
16
+ "slot": "rhd-home-dashboard-slot",
17
+ "title": "actHome"
18
+ }
19
+ },
20
+ {
21
+ "name": "act-home-dashboard",
22
+ "slot": "rhd-home-dashboard-slot",
23
+ "component": "actHomeDashboard"
24
+ },
25
+ {
26
+ "name": "act-home-quick-actions",
27
+ "slot": "rhd-home-widgets-slot",
28
+ "component": "actHomeQuickActions",
29
+ "order": 1
30
+ },
31
+ {
32
+ "name": "act-home-worklists",
33
+ "slot": "rhd-home-widgets-slot",
34
+ "component": "actHomeWorklists",
35
+ "order": 2
36
+ },
37
+ {
38
+ "name": "act-home-care-cascade",
39
+ "slot": "rhd-home-widgets-slot",
40
+ "component": "actHomeCareCascade",
41
+ "order": 4
42
+ },
43
+ {
44
+ "name": "act-registry-dashboard-link",
45
+ "slot": "homepage-dashboard-slot",
46
+ "component": "registryDashboardLink",
47
+ "meta": {
48
+ "name": "act-registry",
49
+ "slot": "rhd-registry-dashboard-slot",
50
+ "title": "registry"
51
+ }
52
+ },
53
+ {
54
+ "name": "act-registry-dashboard",
55
+ "slot": "rhd-registry-dashboard-slot",
56
+ "component": "registryDashboard"
57
+ }
58
+ ],
59
+ "workspaces2": [
60
+ {
61
+ "name": "rhd-flag-gaps-workspace",
62
+ "component": "flagGapsWorkspace",
63
+ "window": "patient-chart-clinical-forms"
64
+ }
65
+ ]
66
+ }