@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,149 @@
1
+ import React, { useCallback } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import {
4
+ Button,
5
+ DataTableSkeleton,
6
+ InlineNotification,
7
+ Table,
8
+ TableBody,
9
+ TableCell,
10
+ TableHead,
11
+ TableHeader,
12
+ TableRow,
13
+ } from '@carbon/react';
14
+ import { formatDate, showSnackbar, Workspace2, type Workspace2DefinitionProps } from '@openmrs/esm-framework';
15
+ import { fetchForm, type FlagGap, type FlagGaps, usePatientFlagGaps } from './flag-gaps.resource';
16
+ import styles from './flag-gaps.scss';
17
+
18
+ /** What a patient flags app that passes the clicked flag launches a flag action's workspace with. */
19
+ export interface FlagActionWorkspaceProps {
20
+ patientUuid: string;
21
+ patientFlagUuid: string;
22
+ flagUuid: string;
23
+ flagName: string;
24
+ }
25
+
26
+ /** The patient chart's workspace group props, which carry the patient when the flag is not passed. */
27
+ interface PatientChartGroupProps {
28
+ patientUuid?: string;
29
+ }
30
+
31
+ const DAY_MS = 24 * 60 * 60 * 1000;
32
+
33
+ export function daysPending(encounterDatetime: string, now = new Date()) {
34
+ return Math.floor((now.getTime() - new Date(encounterDatetime).getTime()) / DAY_MS);
35
+ }
36
+
37
+ const FlagGapsWorkspace: React.FC<
38
+ Workspace2DefinitionProps<Partial<FlagActionWorkspaceProps>, object, PatientChartGroupProps>
39
+ > = ({ workspaceProps, groupProps, launchChildWorkspace }) => {
40
+ const { t } = useTranslation();
41
+ const patientUuid = workspaceProps?.patientUuid ?? groupProps?.patientUuid;
42
+ const clickedFlag = workspaceProps?.flagUuid
43
+ ? { uuid: workspaceProps.flagUuid, name: workspaceProps.flagName }
44
+ : undefined;
45
+ const { flagGaps, isLoading, error } = usePatientFlagGaps(patientUuid, clickedFlag);
46
+ const listed = flagGaps.filter((flag) => flag.configured);
47
+
48
+ const openGap = useCallback(
49
+ async (gap: FlagGap) => {
50
+ try {
51
+ const form = await fetchForm(gap.form.uuid);
52
+ await launchChildWorkspace('patient-form-entry-workspace', { form, encounterUuid: gap.encounter });
53
+ } catch (e) {
54
+ showSnackbar({
55
+ kind: 'error',
56
+ title: t('couldNotOpenForm', 'Could not open the form'),
57
+ subtitle: e?.message,
58
+ });
59
+ }
60
+ },
61
+ [launchChildWorkspace, t],
62
+ );
63
+
64
+ const renderContent = () => {
65
+ if (isLoading) {
66
+ return <DataTableSkeleton role="progressbar" compact zebra={false} showHeader={false} showToolbar={false} />;
67
+ }
68
+ if (error) {
69
+ return (
70
+ <InlineNotification
71
+ kind="error"
72
+ lowContrast
73
+ hideCloseButton
74
+ title={t('couldNotLoadGaps', 'Could not load the missing data')}
75
+ subtitle={error.message}
76
+ />
77
+ );
78
+ }
79
+ if (listed.length === 0) {
80
+ return (
81
+ <p className={styles.message}>
82
+ {clickedFlag
83
+ ? t('noGapList', 'This flag does not list its missing data.')
84
+ : t('noGapLists', "None of this patient's flags lists its missing data.")}
85
+ </p>
86
+ );
87
+ }
88
+ return listed.map((flag) => (
89
+ <section key={flag.flagUuid} className={styles.flag}>
90
+ {clickedFlag ? null : <h4 className={styles.flagName}>{flag.flagName}</h4>}
91
+ {renderGaps(flag)}
92
+ </section>
93
+ ));
94
+ };
95
+
96
+ const renderGaps = ({ gaps }: FlagGaps) => {
97
+ if (gaps.length === 0) {
98
+ // The flag is raised but the data has no saved form to go on yet, so it needs a new one.
99
+ return (
100
+ <>
101
+ <p className={styles.message}>
102
+ {t('noSavedForm', 'No saved form is waiting to be completed. Record the missing data on a new form.')}
103
+ </p>
104
+ <Button kind="ghost" size="sm" onClick={() => launchChildWorkspace('clinical-forms-workspace')}>
105
+ {t('openClinicalForms', 'Open clinical forms')}
106
+ </Button>
107
+ </>
108
+ );
109
+ }
110
+ return (
111
+ <Table size="md" useZebraStyles={false}>
112
+ <TableHead>
113
+ <TableRow>
114
+ <TableHeader>{t('form', 'Form')}</TableHeader>
115
+ <TableHeader>{t('date', 'Date')}</TableHeader>
116
+ <TableHeader>{t('missing', 'Missing')}</TableHeader>
117
+ <TableHeader>{t('daysPending', 'Days pending')}</TableHeader>
118
+ <TableHeader aria-label={t('actions', 'Actions')} />
119
+ </TableRow>
120
+ </TableHead>
121
+ <TableBody>
122
+ {gaps.map((gap) => (
123
+ <TableRow key={`${gap.encounter}-${gap.concept.uuid}`}>
124
+ <TableCell>{gap.form?.display ?? '--'}</TableCell>
125
+ <TableCell>{formatDate(new Date(gap.encounterDatetime), { mode: 'standard', time: false })}</TableCell>
126
+ <TableCell>{gap.concept.display}</TableCell>
127
+ <TableCell>{daysPending(gap.encounterDatetime)}</TableCell>
128
+ <TableCell>
129
+ {gap.form ? (
130
+ <Button kind="ghost" size="sm" onClick={() => openGap(gap)}>
131
+ {t('openForm', 'Open form')}
132
+ </Button>
133
+ ) : null}
134
+ </TableCell>
135
+ </TableRow>
136
+ ))}
137
+ </TableBody>
138
+ </Table>
139
+ );
140
+ };
141
+
142
+ return (
143
+ <Workspace2 title={clickedFlag?.name ?? t('missingData', 'Missing data')}>
144
+ <div className={styles.container}>{renderContent()}</div>
145
+ </Workspace2>
146
+ );
147
+ };
148
+
149
+ export default FlagGapsWorkspace;
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { defineConfigSchema, getAsyncLifecycle, getSyncLifecycle } from '@openmrs/esm-framework';
2
+ import { configSchema } from './config-schema';
3
+ import actHomeDashboardLinkComponent from './act-home/act-home-dashboard-link.component';
4
+ import registryDashboardLinkComponent from './registry/registry-dashboard-link.component';
5
+
6
+ const moduleName = '@mherman22/esm-act-app';
7
+
8
+ const options = {
9
+ featureName: 'rhd-flag-gaps',
10
+ moduleName,
11
+ };
12
+
13
+ export const importTranslation = require.context('../translations', false, /.json$/, 'lazy');
14
+
15
+ export function startupApp() {
16
+ defineConfigSchema(moduleName, configSchema);
17
+ }
18
+
19
+ export const flagGapsWorkspace = getAsyncLifecycle(() => import('./flag-gaps/flag-gaps.workspace'), options);
20
+
21
+ export const actHomeDashboardLink = getSyncLifecycle(actHomeDashboardLinkComponent, options);
22
+
23
+ export const actHomeDashboard = getAsyncLifecycle(() => import('./act-home/act-home-dashboard.component'), options);
24
+
25
+ export const actHomeQuickActions = getAsyncLifecycle(() => import('./act-home/quick-actions.component'), options);
26
+
27
+ export const actHomeWorklists = getAsyncLifecycle(() => import('./act-home/worklist-tiles.component'), options);
28
+
29
+ export const actHomeCareCascade = getAsyncLifecycle(() => import('./act-home/care-cascade.component'), options);
30
+
31
+ export const registryDashboardLink = getSyncLifecycle(registryDashboardLinkComponent, options);
32
+
33
+ export const registryDashboard = getAsyncLifecycle(() => import('./registry/registry.component'), options);
@@ -0,0 +1,16 @@
1
+ /** A cell starting with =, +, -, @, a tab or a carriage return gets a leading ' so a spreadsheet shows it as text. */
2
+ function csvCell(text: string) {
3
+ const value = /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
4
+ return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
5
+ }
6
+
7
+ /** Downloads rows as a CSV file, one line per row with the given headers first. */
8
+ export function downloadCsv(fileName: string, headers: Array<string>, rows: Array<Array<string>>) {
9
+ const csv = [headers, ...rows].map((cells) => cells.map(csvCell).join(',')).join('\r\n') + '\r\n';
10
+ const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
11
+ const link = document.createElement('a');
12
+ link.href = url;
13
+ link.download = fileName;
14
+ link.click();
15
+ URL.revokeObjectURL(url);
16
+ }
@@ -0,0 +1,84 @@
1
+ import React from 'react';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { render, screen, within } from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
5
+ import { signInWith } from '../access/sign-in.test-helper';
6
+ import { useReportDataset } from '../reports/report-dataset.resource';
7
+ import { registryRows } from './registry.fixture';
8
+ import Registry from './registry.component';
9
+
10
+ vi.mock('../reports/report-dataset.resource', () => ({ useReportDataset: vi.fn() }));
11
+
12
+ const withBpg = registryRows.map((row, i) => ({
13
+ ...row,
14
+ bpg_status: ['Covered', 'Not covered', 'Due soon'][i % 3],
15
+ adherence: 50 + i,
16
+ }));
17
+
18
+ const bpgOn = { registry: { report: 'f1a2b3c4-d5e6-7890-abcd-ef1234567890', showBpgColumns: true } };
19
+
20
+ describe('Registry BPG status and adherence', () => {
21
+ beforeEach(() => {
22
+ window.getOpenmrsSpaBase = () => '/openmrs/spa/';
23
+ window.history.replaceState(null, '', '/openmrs/spa/home/act-registry');
24
+ vi.mocked(useReportDataset).mockReturnValue({ columns: [], rows: withBpg, isLoading: false, error: undefined });
25
+ });
26
+
27
+ it('leaves the columns and the filter out while the setting is off', async () => {
28
+ await signInWith(['View Patient Flags']);
29
+
30
+ render(<Registry />);
31
+
32
+ const headers = screen.getAllByRole('columnheader').map((header) => header.textContent);
33
+ expect(headers).not.toContain('BPG status');
34
+ expect(headers).not.toContain('Adherence');
35
+ expect(screen.queryByLabelText('BPG status')).not.toBeInTheDocument();
36
+ });
37
+
38
+ it('ignores a BPG status in the URL while the setting is off', async () => {
39
+ window.history.replaceState(null, '', '/openmrs/spa/home/act-registry?bpg=Due+soon');
40
+ await signInWith(['View Patient Flags']);
41
+
42
+ render(<Registry />);
43
+
44
+ expect(screen.getAllByRole('row')).toHaveLength(1 + 25);
45
+ });
46
+
47
+ it('leaves the adherence empty for a patient who has none', async () => {
48
+ await signInWith(['View Patient Flags'], bpgOn);
49
+ vi.mocked(useReportDataset).mockReturnValue({
50
+ columns: [],
51
+ rows: [{ ...withBpg[0], adherence: null }],
52
+ isLoading: false,
53
+ error: undefined,
54
+ });
55
+
56
+ render(<Registry />);
57
+
58
+ const headers = screen.getAllByRole('columnheader').map((header) => header.textContent);
59
+ const row = within(screen.getByRole('row', { name: /Patient 1\b/ })).getAllByRole('cell');
60
+ expect(row[headers.indexOf('Adherence')]).toHaveTextContent(/^$/);
61
+ });
62
+
63
+ it('shows each patient’s BPG status and adherence once turned on', async () => {
64
+ await signInWith(['View Patient Flags'], bpgOn);
65
+
66
+ render(<Registry />);
67
+
68
+ const headers = screen.getAllByRole('columnheader').map((header) => header.textContent);
69
+ const row = within(screen.getByRole('row', { name: /Patient 2\b/ })).getAllByRole('cell');
70
+ expect(row[headers.indexOf('BPG status')]).toHaveTextContent('Not covered');
71
+ expect(row[headers.indexOf('Adherence')]).toHaveTextContent('51%');
72
+ });
73
+
74
+ it('narrows the rows by BPG status', async () => {
75
+ await signInWith(['View Patient Flags'], bpgOn);
76
+
77
+ render(<Registry />);
78
+
79
+ await userEvent.selectOptions(screen.getByLabelText('BPG status'), 'Due soon');
80
+ const rows = screen.getAllByRole('row').slice(1);
81
+ expect(rows).toHaveLength(10);
82
+ expect(rows.every((r) => within(r).queryByText('Due soon'))).toBe(true);
83
+ });
84
+ });
@@ -0,0 +1,100 @@
1
+ import React from 'react';
2
+ import { 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 { signInWith } from '../access/sign-in.test-helper';
6
+ import { useReportDataset } from '../reports/report-dataset.resource';
7
+ import { registryRows } from './registry.fixture';
8
+ import Registry from './registry.component';
9
+
10
+ vi.mock('../reports/report-dataset.resource', () => ({ useReportDataset: vi.fn() }));
11
+
12
+ async function downloadedCsv() {
13
+ const createObjectURL = vi.fn((blob: Blob) => 'blob:registry');
14
+ window.URL.createObjectURL = createObjectURL as never;
15
+ window.URL.revokeObjectURL = vi.fn();
16
+ await userEvent.click(screen.getByRole('button', { name: /download csv/i }));
17
+ return (createObjectURL.mock.calls[0][0] as Blob).text();
18
+ }
19
+
20
+ describe('Registry CSV download', () => {
21
+ beforeEach(async () => {
22
+ window.getOpenmrsSpaBase = () => '/openmrs/spa/';
23
+ window.history.replaceState(null, '', '/openmrs/spa/home/act-registry');
24
+ await signInWith(['View Patient Flags']);
25
+ vi.mocked(useReportDataset).mockReturnValue({
26
+ columns: [],
27
+ rows: registryRows,
28
+ isLoading: false,
29
+ error: undefined,
30
+ });
31
+ });
32
+
33
+ it('downloads the filtered rows, from every page, with the visible columns as shown', async () => {
34
+ render(<Registry />);
35
+ await userEvent.selectOptions(screen.getByLabelText('Category at diagnosis'), 'RHD A');
36
+
37
+ const lines = (await downloadedCsv()).trim().split('\r\n');
38
+
39
+ expect(lines[0]).toBe('Name,ACT ID,Age,Sex,Diagnosis category,Prophylaxis regimen,Next consultation,Flags');
40
+ expect(lines).toHaveLength(1 + 10);
41
+ expect(lines[1]).toBe('Patient 1,rhd00001,10,F,RHD A,Q28 day BPG,15-Oct-2026,');
42
+ });
43
+
44
+ it('includes rows past the first page', async () => {
45
+ render(<Registry />);
46
+
47
+ const lines = (await downloadedCsv()).trim().split('\r\n');
48
+
49
+ expect(lines).toHaveLength(1 + 30);
50
+ });
51
+
52
+ it("writes each patient's flags as the table shows them", async () => {
53
+ vi.mocked(useReportDataset).mockReturnValue({
54
+ columns: [],
55
+ rows: [{ ...registryRows[0], rhd_flags: 'RHD INR target missing|RHD prophylaxis overdue' }],
56
+ isLoading: false,
57
+ error: undefined,
58
+ });
59
+ render(<Registry />);
60
+
61
+ const lines = (await downloadedCsv()).trim().split('\r\n');
62
+
63
+ expect(lines[1].endsWith(',RHD INR target missing; RHD prophylaxis overdue')).toBe(true);
64
+ });
65
+
66
+ it('quotes a value holding a comma or a quote', async () => {
67
+ vi.mocked(useReportDataset).mockReturnValue({
68
+ columns: [],
69
+ rows: [{ ...registryRows[0], full_name: 'Nambi, Esther "Essie"' }],
70
+ isLoading: false,
71
+ error: undefined,
72
+ });
73
+ render(<Registry />);
74
+
75
+ const lines = (await downloadedCsv()).trim().split('\r\n');
76
+
77
+ expect(lines[1].startsWith('"Nambi, Esther ""Essie""",')).toBe(true);
78
+ });
79
+
80
+ it.each([
81
+ ['=HYPERLINK("http://x")', `"'=HYPERLINK(""http://x"")"`],
82
+ ['+256 700', "'+256 700"],
83
+ ['-1', "'-1"],
84
+ ['@SUM(A1)', "'@SUM(A1)"],
85
+ ['\t=1+1', "'\t=1+1"],
86
+ ['\r=1+1', `"'\r=1+1"`],
87
+ ])('writes %s as text, so a spreadsheet does not run it', async (name, cell) => {
88
+ vi.mocked(useReportDataset).mockReturnValue({
89
+ columns: [],
90
+ rows: [{ ...registryRows[0], full_name: name }],
91
+ isLoading: false,
92
+ error: undefined,
93
+ });
94
+ render(<Registry />);
95
+
96
+ const lines = (await downloadedCsv()).trim().split('\r\n');
97
+
98
+ expect(lines[1].startsWith(`${cell},`)).toBe(true);
99
+ });
100
+ });
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ import { ActDashboardLink } from '../access/act-dashboard-link.component';
3
+ import { registryDashboardMeta } from './registry.meta';
4
+
5
+ export default function RegistryDashboardLink() {
6
+ return <ActDashboardLink screen="registry" meta={registryDashboardMeta} />;
7
+ }
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { render } from '@testing-library/react';
4
+ import { DashboardExtension } from '@openmrs/esm-framework';
5
+ import { signInWith } from '../access/sign-in.test-helper';
6
+ import RegistryDashboardLink from './registry-dashboard-link.component';
7
+
8
+ vi.mock('@openmrs/esm-framework', async (importOriginal) => ({
9
+ ...(await importOriginal<object>()),
10
+ DashboardExtension: vi.fn(() => null),
11
+ }));
12
+
13
+ describe('RegistryDashboardLink', () => {
14
+ it('links the home left nav to /home/act-registry', async () => {
15
+ window.spaBase = '/openmrs/spa';
16
+ await signInWith(['View Patient Flags']);
17
+
18
+ render(<RegistryDashboardLink />);
19
+
20
+ expect(vi.mocked(DashboardExtension).mock.calls[0][0]).toEqual(
21
+ expect.objectContaining({ path: 'act-registry', basePath: '/openmrs/spa/home', title: 'registry' }),
22
+ );
23
+ });
24
+
25
+ it('is hidden from a user without the registry privilege', async () => {
26
+ await signInWith(['Get Patients']);
27
+
28
+ render(<RegistryDashboardLink />);
29
+
30
+ expect(vi.mocked(DashboardExtension)).not.toHaveBeenCalled();
31
+ });
32
+ });
@@ -0,0 +1,218 @@
1
+ import React from 'react';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
5
+ import { signInWith } from '../access/sign-in.test-helper';
6
+ import { useReportDataset } from '../reports/report-dataset.resource';
7
+ import { registryRows } from './registry.fixture';
8
+ import Registry from './registry.component';
9
+
10
+ vi.mock('../reports/report-dataset.resource', () => ({ useReportDataset: vi.fn() }));
11
+
12
+ function shownNames() {
13
+ return screen
14
+ .getAllByRole('row')
15
+ .slice(1)
16
+ .map((row) => row.querySelector('td').textContent);
17
+ }
18
+
19
+ async function choose(label: string, option: string) {
20
+ await userEvent.selectOptions(screen.getByLabelText(label), option);
21
+ }
22
+
23
+ describe('Registry filters', () => {
24
+ beforeEach(async () => {
25
+ window.getOpenmrsSpaBase = () => '/openmrs/spa/';
26
+ window.history.replaceState(null, '', '/openmrs/spa/home/act-registry');
27
+ await signInWith(['View Patient Flags']);
28
+ vi.mocked(useReportDataset).mockReturnValue({
29
+ columns: [],
30
+ rows: registryRows,
31
+ isLoading: false,
32
+ error: undefined,
33
+ });
34
+ });
35
+
36
+ it('narrows the rows by status', async () => {
37
+ render(<Registry />);
38
+
39
+ await choose('Status', 'Completed');
40
+
41
+ expect(shownNames()).toEqual(['Patient 4']);
42
+ });
43
+
44
+ it('narrows the rows by cardiac clinic, offering the clinics the patients are assigned to', async () => {
45
+ render(<Registry />);
46
+
47
+ await screen.findByRole('option', { name: 'Lira RRH' });
48
+ expect(screen.getAllByRole('option', { name: /RRH/ }).map((option) => option.textContent)).toEqual([
49
+ 'Gulu RRH',
50
+ 'Lira RRH',
51
+ ]);
52
+ await choose('Cardiac clinic', 'Gulu RRH');
53
+
54
+ expect(shownNames()).toHaveLength(15);
55
+ expect(shownNames().every((name) => Number(name.split(' ')[1]) % 2 === 0)).toBe(true);
56
+ });
57
+
58
+ it('narrows the rows by primary care clinic', async () => {
59
+ render(<Registry />);
60
+
61
+ expect(
62
+ within(screen.getByLabelText('Primary care clinic'))
63
+ .getAllByRole('option')
64
+ .map((option) => option.textContent),
65
+ ).toEqual(['All', 'Anyeke HCIV']);
66
+ await choose('Primary care clinic', 'Anyeke HCIV');
67
+
68
+ expect(shownNames()).toHaveLength(15);
69
+ });
70
+
71
+ it('narrows the rows by category at diagnosis', async () => {
72
+ render(<Registry />);
73
+
74
+ await choose('Category at diagnosis', 'RHD B');
75
+
76
+ expect(shownNames()).toEqual([
77
+ 'Patient 2',
78
+ 'Patient 5',
79
+ 'Patient 8',
80
+ 'Patient 11',
81
+ 'Patient 14',
82
+ 'Patient 17',
83
+ 'Patient 20',
84
+ 'Patient 23',
85
+ 'Patient 26',
86
+ 'Patient 29',
87
+ ]);
88
+ });
89
+
90
+ it("keeps offering every patient's values once a filter is set, so another can be picked", async () => {
91
+ render(<Registry />);
92
+
93
+ await choose('Category at diagnosis', 'RHD A');
94
+ await choose('Status', 'Completed');
95
+
96
+ const options = (label: string) =>
97
+ within(screen.getByLabelText(label))
98
+ .getAllByRole('option')
99
+ .map((option) => option.textContent);
100
+ expect(options('Category at diagnosis')).toEqual(['All', 'RHD A', 'RHD B', 'RHD C']);
101
+ expect(options('Status')).toEqual(['All', 'Active', 'Completed']);
102
+ });
103
+
104
+ it('finds patients by name or ACT ID', async () => {
105
+ render(<Registry />);
106
+
107
+ await userEvent.type(screen.getByRole('searchbox'), 'rhd00012');
108
+ expect(shownNames()).toEqual(['Patient 12']);
109
+ await userEvent.clear(screen.getByRole('searchbox'));
110
+ await userEvent.type(screen.getByRole('searchbox'), 'patient 3');
111
+ expect(shownNames()).toEqual(['Patient 3', 'Patient 30']);
112
+ });
113
+
114
+ it('goes back to the first page when a filter changes', async () => {
115
+ render(<Registry />);
116
+
117
+ await userEvent.click(screen.getByRole('button', { name: /next page/i }));
118
+ await choose('Status', 'Active');
119
+
120
+ expect(shownNames()[0]).toBe('Patient 1');
121
+ });
122
+
123
+ it('shows a filter value from the URL that no patient has, so it can be cleared', async () => {
124
+ window.history.replaceState(null, '', '/openmrs/spa/home/act-registry?cardiac=Old+clinic');
125
+
126
+ render(<Registry />);
127
+
128
+ expect(screen.getByLabelText('Cardiac clinic')).toHaveValue('Old clinic');
129
+ expect(screen.getByText('No patients match these filters.')).toBeInTheDocument();
130
+ await choose('Cardiac clinic', '');
131
+ expect(shownNames()).toHaveLength(25);
132
+ });
133
+
134
+ it('follows the URL when the Registry link resets it', async () => {
135
+ window.history.replaceState(null, '', '/openmrs/spa/home/act-registry?category=RHD+B');
136
+ render(<Registry />);
137
+ expect(shownNames()).toHaveLength(10);
138
+
139
+ act(() => {
140
+ window.history.replaceState(null, '', '/openmrs/spa/home/act-registry');
141
+ window.dispatchEvent(new PopStateEvent('popstate'));
142
+ });
143
+
144
+ expect(screen.getByLabelText('Category at diagnosis')).toHaveValue('');
145
+ expect(shownNames()).toHaveLength(25);
146
+ });
147
+
148
+ it('starts again from the first page when the Registry link resets the filters', async () => {
149
+ window.history.replaceState(null, '', '/openmrs/spa/home/act-registry?status=Active');
150
+ render(<Registry />);
151
+ await userEvent.click(screen.getByRole('button', { name: /next page/i }));
152
+
153
+ act(() => {
154
+ window.history.replaceState(null, '', '/openmrs/spa/home/act-registry');
155
+ window.dispatchEvent(new PopStateEvent('popstate'));
156
+ });
157
+
158
+ expect(shownNames()[0]).toBe('Patient 1');
159
+ });
160
+
161
+ it('keeps the page when another app changes the URL but not the filters', async () => {
162
+ render(<Registry />);
163
+ await userEvent.click(screen.getByRole('button', { name: /next page/i }));
164
+
165
+ act(() => {
166
+ window.dispatchEvent(new PopStateEvent('popstate'));
167
+ });
168
+
169
+ expect(shownNames()[0]).toBe('Patient 26');
170
+ });
171
+
172
+ it('writes the URL once typing has paused, not on every key', () => {
173
+ vi.useFakeTimers();
174
+ render(<Registry />);
175
+
176
+ fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'pat' } });
177
+ act(() => vi.advanceTimersByTime(299));
178
+ expect(window.location.search).toBe('');
179
+ act(() => vi.advanceTimersByTime(1));
180
+ vi.useRealTimers();
181
+
182
+ expect(new URLSearchParams(window.location.search).get('q')).toBe('pat');
183
+ });
184
+
185
+ it('says so when no patient matches the filters', async () => {
186
+ render(<Registry />);
187
+
188
+ await userEvent.type(screen.getByRole('searchbox'), 'nobody');
189
+
190
+ expect(screen.getByText('No patients match these filters.')).toBeInTheDocument();
191
+ expect(screen.queryByLabelText(/items per page/i)).not.toBeInTheDocument();
192
+ });
193
+
194
+ it('takes a filter set back to All out of the URL', async () => {
195
+ window.history.replaceState(null, '', '/openmrs/spa/home/act-registry?category=RHD+B&status=Active');
196
+ render(<Registry />);
197
+
198
+ await choose('Category at diagnosis', '');
199
+
200
+ await waitFor(() => expect(new URLSearchParams(window.location.search).has('category')).toBe(false));
201
+ expect(new URLSearchParams(window.location.search).get('status')).toBe('Active');
202
+ });
203
+
204
+ it('keeps the filters in the URL and restores them from it', async () => {
205
+ const { unmount } = render(<Registry />);
206
+ await choose('Category at diagnosis', 'RHD C');
207
+ await userEvent.type(screen.getByRole('searchbox'), 'patient 1');
208
+ await waitFor(() => expect(new URLSearchParams(window.location.search).get('q')).toBe('patient 1'));
209
+ expect(new URLSearchParams(window.location.search).get('category')).toBe('RHD C');
210
+ unmount();
211
+
212
+ render(<Registry />);
213
+
214
+ expect(screen.getByLabelText('Category at diagnosis')).toHaveValue('RHD C');
215
+ expect(screen.getByRole('searchbox')).toHaveValue('patient 1');
216
+ expect(shownNames()).toEqual(['Patient 12', 'Patient 15', 'Patient 18']);
217
+ });
218
+ });