@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,81 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { type ReportRow } from '../reports/report-dataset.resource';
|
|
3
|
+
|
|
4
|
+
export interface RegistryFilters {
|
|
5
|
+
status: string;
|
|
6
|
+
cardiac: string;
|
|
7
|
+
primaryCare: string;
|
|
8
|
+
category: string;
|
|
9
|
+
bpg: string;
|
|
10
|
+
q: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const keys: Array<keyof RegistryFilters> = ['status', 'cardiac', 'primaryCare', 'category', 'bpg', 'q'];
|
|
14
|
+
|
|
15
|
+
function fromUrl(): RegistryFilters {
|
|
16
|
+
const params = new URLSearchParams(window.location.search);
|
|
17
|
+
return Object.fromEntries(keys.map((key) => [key, params.get(key) ?? ''])) as unknown as RegistryFilters;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The registry's filters, kept in the page's URL so a view can be bookmarked. */
|
|
21
|
+
export function useRegistryFilters() {
|
|
22
|
+
const [filters, setFilters] = useState(fromUrl);
|
|
23
|
+
|
|
24
|
+
// The URL follows the filters a moment after typing stops, as each write reroutes every single-spa app.
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const timer = setTimeout(() => {
|
|
27
|
+
const params = new URLSearchParams(window.location.search);
|
|
28
|
+
keys.forEach((key) => (filters[key] ? params.set(key, filters[key]) : params.delete(key)));
|
|
29
|
+
const url = `${window.location.pathname}${params.size ? `?${params}` : ''}`;
|
|
30
|
+
if (url !== `${window.location.pathname}${window.location.search}`) {
|
|
31
|
+
window.history.replaceState(window.history.state, '', url);
|
|
32
|
+
}
|
|
33
|
+
}, 300);
|
|
34
|
+
return () => clearTimeout(timer);
|
|
35
|
+
}, [filters]);
|
|
36
|
+
|
|
37
|
+
// Following the Registry link again resets its URL, and with it the filters.
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
// Other apps' URL changes reach here too; filters that are unchanged keep the page where it is.
|
|
40
|
+
const reread = () =>
|
|
41
|
+
setFilters((current) => {
|
|
42
|
+
const next = fromUrl();
|
|
43
|
+
return keys.every((key) => current[key] === next[key]) ? current : next;
|
|
44
|
+
});
|
|
45
|
+
window.addEventListener('popstate', reread);
|
|
46
|
+
return () => window.removeEventListener('popstate', reread);
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const update = (change: Partial<RegistryFilters>) => setFilters((current) => ({ ...current, ...change }));
|
|
50
|
+
return [filters, update] as const;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function filterRegistry(rows: Array<ReportRow>, filters: RegistryFilters) {
|
|
54
|
+
const q = filters.q.trim().toLowerCase();
|
|
55
|
+
return rows.filter(
|
|
56
|
+
(row) =>
|
|
57
|
+
(!filters.status || row.enrollment_status === filters.status) &&
|
|
58
|
+
(!filters.cardiac || row.cardiac_clinic === filters.cardiac) &&
|
|
59
|
+
(!filters.primaryCare || row.primary_care_clinic === filters.primaryCare) &&
|
|
60
|
+
(!filters.category || row.diagnosis_category === filters.category) &&
|
|
61
|
+
(!filters.bpg || row.bpg_status === filters.bpg) &&
|
|
62
|
+
(!q ||
|
|
63
|
+
[row.full_name, row.rhd_id].some((value) =>
|
|
64
|
+
String(value ?? '')
|
|
65
|
+
.toLowerCase()
|
|
66
|
+
.includes(q),
|
|
67
|
+
)),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The distinct values a column takes, sorted, for a filter's options. */
|
|
72
|
+
export function distinctValues(rows: Array<ReportRow>, column: string) {
|
|
73
|
+
return [
|
|
74
|
+
...new Set(
|
|
75
|
+
rows
|
|
76
|
+
.map((row) => row[column])
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.map(String),
|
|
79
|
+
),
|
|
80
|
+
].sort();
|
|
81
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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 { signInWith } from '../access/sign-in.test-helper';
|
|
5
|
+
import { useReportDataset } from '../reports/report-dataset.resource';
|
|
6
|
+
import { registryRows } from './registry.fixture';
|
|
7
|
+
import Registry from './registry.component';
|
|
8
|
+
|
|
9
|
+
vi.mock('../reports/report-dataset.resource', () => ({ useReportDataset: vi.fn() }));
|
|
10
|
+
|
|
11
|
+
const flaggedRows = registryRows.map((row, i) => ({
|
|
12
|
+
...row,
|
|
13
|
+
rhd_flags: ['RHD INR target missing|RHD prophylaxis overdue', 'RHD prophylaxis overdue', null][i] ?? null,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
function flagsOf(name: string) {
|
|
17
|
+
return within(screen.getByRole('row', { name: new RegExp(`${name}\\b`) }))
|
|
18
|
+
.queryAllByTestId('registry-flag')
|
|
19
|
+
.map((tag) => [tag.textContent, tag.getAttribute('data-priority'), tag.className.includes('cds--tag--red')]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('Registry flags column', () => {
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
window.getOpenmrsSpaBase = () => '/openmrs/spa/';
|
|
25
|
+
window.history.replaceState(null, '', '/openmrs/spa/home/act-registry');
|
|
26
|
+
await signInWith(['View Patient Flags']);
|
|
27
|
+
vi.mocked(useReportDataset).mockReturnValue({ columns: [], rows: flaggedRows, isLoading: false, error: undefined });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("shows each patient's RHD flags from the report, risk flags red and missing data flags orange", () => {
|
|
31
|
+
render(<Registry />);
|
|
32
|
+
|
|
33
|
+
expect(screen.getAllByRole('columnheader').map((header) => header.textContent)).toContain('Flags');
|
|
34
|
+
expect(flagsOf('Patient 1')).toEqual([
|
|
35
|
+
['RHD INR target missing', 'dataQuality', false],
|
|
36
|
+
['RHD prophylaxis overdue', 'risk', true],
|
|
37
|
+
]);
|
|
38
|
+
expect(flagsOf('Patient 2')).toEqual([['RHD prophylaxis overdue', 'risk', true]]);
|
|
39
|
+
expect(flagsOf('Patient 3')).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it.each([
|
|
43
|
+
['names', { namePrefix: 'RHD ', names: ['RHD prophylaxis overdue'] }],
|
|
44
|
+
['namePrefix', { namePrefix: 'RHD prophylaxis', names: [] }],
|
|
45
|
+
])('shows only the flags the configuration selects by %s', async (_, selection) => {
|
|
46
|
+
await signInWith(['View Patient Flags'], {
|
|
47
|
+
flagLists: { riskFlags: ['RHD prophylaxis overdue'], ...selection },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
render(<Registry />);
|
|
51
|
+
|
|
52
|
+
expect(flagsOf('Patient 1')).toEqual([['RHD prophylaxis overdue', 'risk', true]]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('takes the risk flags from the configuration', async () => {
|
|
56
|
+
await signInWith(['View Patient Flags'], {
|
|
57
|
+
flagLists: { namePrefix: 'RHD ', names: [], riskFlags: ['RHD INR target missing'] },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
render(<Registry />);
|
|
61
|
+
|
|
62
|
+
expect(flagsOf('Patient 1')).toEqual([
|
|
63
|
+
['RHD INR target missing', 'risk', true],
|
|
64
|
+
['RHD prophylaxis overdue', 'dataQuality', false],
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import dayjs from 'dayjs';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import {
|
|
5
|
+
Button,
|
|
6
|
+
DataTableSkeleton,
|
|
7
|
+
InlineNotification,
|
|
8
|
+
Pagination,
|
|
9
|
+
Search,
|
|
10
|
+
Select,
|
|
11
|
+
SelectItem,
|
|
12
|
+
Table,
|
|
13
|
+
Tag,
|
|
14
|
+
TableBody,
|
|
15
|
+
TableCell,
|
|
16
|
+
TableHead,
|
|
17
|
+
TableHeader,
|
|
18
|
+
TableRow,
|
|
19
|
+
} from '@carbon/react';
|
|
20
|
+
import { ConfigurableLink, formatDate, navigate, useConfig, usePagination } from '@openmrs/esm-framework';
|
|
21
|
+
import { type Config } from '../config-schema';
|
|
22
|
+
import { useScreenAccess } from '../access/screen-access.component';
|
|
23
|
+
import { flagPriority, isListedFlag } from '../rhd-flags/rhd-flag-lists.resource';
|
|
24
|
+
import { useReportDataset, type ReportRow } from '../reports/report-dataset.resource';
|
|
25
|
+
import { parseReportDate } from '../reports/report-date';
|
|
26
|
+
import { downloadCsv } from './csv';
|
|
27
|
+
import { distinctValues, filterRegistry, useRegistryFilters } from './registry-filters';
|
|
28
|
+
import styles from './registry.scss';
|
|
29
|
+
|
|
30
|
+
const pageSizes = [25, 50, 100];
|
|
31
|
+
|
|
32
|
+
const chartUrl = (row: ReportRow) => '${openmrsSpaBase}' + `/patient/${row.patient_uuid}/chart`;
|
|
33
|
+
|
|
34
|
+
function nextConsultation(row: ReportRow) {
|
|
35
|
+
const date = parseReportDate(row.next_consultation_date);
|
|
36
|
+
return date ? formatDate(date, { time: false, noToday: true }) : '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function RegistryTable() {
|
|
40
|
+
const { t } = useTranslation();
|
|
41
|
+
const { registry, flagLists } = useConfig<Config>();
|
|
42
|
+
const params = useMemo(() => ({ startDate: '1900-01-01', endDate: dayjs().format('YYYY-MM-DD') }), []);
|
|
43
|
+
// Coming back from a chart paints the cached rows, then evaluates the report again for what the chart changed.
|
|
44
|
+
const { rows, isLoading, error } = useReportDataset(registry.report, params);
|
|
45
|
+
const [filters, setFilters] = useRegistryFilters();
|
|
46
|
+
// A BPG status in the URL has no filter to clear it while the setting is off, so it is not applied.
|
|
47
|
+
const shown = useMemo(
|
|
48
|
+
() => filterRegistry(rows, registry.showBpgColumns ? filters : { ...filters, bpg: '' }),
|
|
49
|
+
[rows, filters, registry.showBpgColumns],
|
|
50
|
+
);
|
|
51
|
+
const [pageSize, setPageSize] = useState(pageSizes[0]);
|
|
52
|
+
const { results, currentPage, goTo } = usePagination(shown, pageSize);
|
|
53
|
+
// A change of filters, chosen here or by following the Registry link again, starts from the first page.
|
|
54
|
+
useEffect(() => goTo(1), [filters, goTo]);
|
|
55
|
+
const filterSelect = (id: string, label: string, value: string, options: Array<string>, key: string) => (
|
|
56
|
+
<Select id={id} labelText={label} value={value} onChange={(event) => setFilters({ [key]: event.target.value })}>
|
|
57
|
+
<SelectItem value="" text={t('all', 'All')} />
|
|
58
|
+
{/* A value from a bookmarked URL that no longer matches still shows, so it can be cleared. */}
|
|
59
|
+
{[...options, ...(value && !options.includes(value) ? [value] : [])].map((option) => (
|
|
60
|
+
<SelectItem key={option} value={option} text={option} />
|
|
61
|
+
))}
|
|
62
|
+
</Select>
|
|
63
|
+
);
|
|
64
|
+
// The report lists the flags whose patient lists each patient is on, separated by |; the configured ones show.
|
|
65
|
+
const flagsOf = (row: ReportRow) =>
|
|
66
|
+
String(row.rhd_flags ?? '')
|
|
67
|
+
.split('|')
|
|
68
|
+
.filter((flagName) => flagName && isListedFlag(flagLists, flagName))
|
|
69
|
+
.map((flagName) => ({ flagName, priority: flagPriority(flagLists, flagName) }));
|
|
70
|
+
const text = (column: string) => (row: ReportRow) => String(row[column] ?? '');
|
|
71
|
+
const columns: Array<{
|
|
72
|
+
header: string;
|
|
73
|
+
text: (row: ReportRow) => string;
|
|
74
|
+
render?: (row: ReportRow) => React.ReactNode;
|
|
75
|
+
}> = [
|
|
76
|
+
{
|
|
77
|
+
header: t('name', 'Name'),
|
|
78
|
+
text: text('full_name'),
|
|
79
|
+
render: (row) => <ConfigurableLink to={chartUrl(row)}>{String(row.full_name ?? '')}</ConfigurableLink>,
|
|
80
|
+
},
|
|
81
|
+
{ header: t('actId', 'ACT ID'), text: text('rhd_id') },
|
|
82
|
+
{ header: t('age', 'Age'), text: text('age_years') },
|
|
83
|
+
{ header: t('sex', 'Sex'), text: text('sex') },
|
|
84
|
+
{ header: t('diagnosisCategory', 'Diagnosis category'), text: text('diagnosis_category') },
|
|
85
|
+
{ header: t('prophylaxisRegimen', 'Prophylaxis regimen'), text: text('prophylaxis_regimen') },
|
|
86
|
+
{ header: t('nextConsultation', 'Next consultation'), text: nextConsultation },
|
|
87
|
+
...(registry.showBpgColumns
|
|
88
|
+
? [
|
|
89
|
+
{ header: t('bpgStatus', 'BPG status'), text: text('bpg_status') },
|
|
90
|
+
{
|
|
91
|
+
header: t('adherence', 'Adherence'),
|
|
92
|
+
text: (row: ReportRow) => (row.adherence == null ? '' : `${row.adherence}%`),
|
|
93
|
+
},
|
|
94
|
+
]
|
|
95
|
+
: []),
|
|
96
|
+
{
|
|
97
|
+
header: t('flags', 'Flags'),
|
|
98
|
+
text: (row) =>
|
|
99
|
+
flagsOf(row)
|
|
100
|
+
.map((list) => list.flagName)
|
|
101
|
+
.join('; '),
|
|
102
|
+
render: (row) =>
|
|
103
|
+
flagsOf(row).map((list) => (
|
|
104
|
+
<Tag
|
|
105
|
+
key={list.flagName}
|
|
106
|
+
data-testid="registry-flag"
|
|
107
|
+
data-priority={list.priority}
|
|
108
|
+
type={list.priority === 'risk' ? 'red' : 'warm-gray'}
|
|
109
|
+
className={styles[list.priority]}
|
|
110
|
+
size="sm"
|
|
111
|
+
>
|
|
112
|
+
{list.flagName}
|
|
113
|
+
</Tag>
|
|
114
|
+
)),
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
if (error) {
|
|
119
|
+
return (
|
|
120
|
+
<InlineNotification
|
|
121
|
+
kind="error"
|
|
122
|
+
lowContrast
|
|
123
|
+
hideCloseButton
|
|
124
|
+
title={t('couldNotLoadRegistry', 'Could not load the registry')}
|
|
125
|
+
/>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (isLoading) {
|
|
129
|
+
return (
|
|
130
|
+
<div data-testid="registry-loading">
|
|
131
|
+
<DataTableSkeleton columnCount={columns.length} showHeader={false} showToolbar={false} />
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (!rows.length) {
|
|
136
|
+
return <p className={styles.message}>{t('noRegistryPatients', 'No patients are enrolled in the registry.')}</p>;
|
|
137
|
+
}
|
|
138
|
+
return (
|
|
139
|
+
<>
|
|
140
|
+
<div className={styles.filters}>
|
|
141
|
+
<Search
|
|
142
|
+
labelText={t('searchRegistry', 'Search by name or ACT ID')}
|
|
143
|
+
placeholder={t('searchRegistry', 'Search by name or ACT ID')}
|
|
144
|
+
value={filters.q}
|
|
145
|
+
onChange={(event) => setFilters({ q: event.target.value })}
|
|
146
|
+
/>
|
|
147
|
+
{filterSelect(
|
|
148
|
+
'registry-status',
|
|
149
|
+
t('status', 'Status'),
|
|
150
|
+
filters.status,
|
|
151
|
+
distinctValues(rows, 'enrollment_status'),
|
|
152
|
+
'status',
|
|
153
|
+
)}
|
|
154
|
+
{filterSelect(
|
|
155
|
+
'registry-cardiac',
|
|
156
|
+
t('cardiacClinic', 'Cardiac clinic'),
|
|
157
|
+
filters.cardiac,
|
|
158
|
+
distinctValues(rows, 'cardiac_clinic'),
|
|
159
|
+
'cardiac',
|
|
160
|
+
)}
|
|
161
|
+
{filterSelect(
|
|
162
|
+
'registry-primary-care',
|
|
163
|
+
t('primaryCareClinic', 'Primary care clinic'),
|
|
164
|
+
filters.primaryCare,
|
|
165
|
+
distinctValues(rows, 'primary_care_clinic'),
|
|
166
|
+
'primaryCare',
|
|
167
|
+
)}
|
|
168
|
+
{filterSelect(
|
|
169
|
+
'registry-category',
|
|
170
|
+
t('categoryAtDiagnosis', 'Category at diagnosis'),
|
|
171
|
+
filters.category,
|
|
172
|
+
distinctValues(rows, 'diagnosis_category'),
|
|
173
|
+
'category',
|
|
174
|
+
)}
|
|
175
|
+
{registry.showBpgColumns &&
|
|
176
|
+
filterSelect(
|
|
177
|
+
'registry-bpg',
|
|
178
|
+
t('bpgStatus', 'BPG status'),
|
|
179
|
+
filters.bpg,
|
|
180
|
+
distinctValues(rows, 'bpg_status'),
|
|
181
|
+
'bpg',
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
<div className={styles.actions}>
|
|
185
|
+
<Button
|
|
186
|
+
kind="tertiary"
|
|
187
|
+
size="sm"
|
|
188
|
+
disabled={!shown.length}
|
|
189
|
+
onClick={() =>
|
|
190
|
+
downloadCsv(
|
|
191
|
+
`registry-${dayjs().format('YYYY-MM-DD')}.csv`,
|
|
192
|
+
columns.map((column) => column.header),
|
|
193
|
+
shown.map((row) => columns.map((column) => column.text(row))),
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
>
|
|
197
|
+
{t('downloadCsv', 'Download CSV')}
|
|
198
|
+
</Button>
|
|
199
|
+
</div>
|
|
200
|
+
{shown.length ? (
|
|
201
|
+
<Table>
|
|
202
|
+
<TableHead>
|
|
203
|
+
<TableRow>
|
|
204
|
+
{columns.map((column) => (
|
|
205
|
+
<TableHeader key={column.header}>{column.header}</TableHeader>
|
|
206
|
+
))}
|
|
207
|
+
</TableRow>
|
|
208
|
+
</TableHead>
|
|
209
|
+
<TableBody>
|
|
210
|
+
{results.map((row) => (
|
|
211
|
+
<TableRow
|
|
212
|
+
key={String(row.patient_uuid)}
|
|
213
|
+
className={styles.row}
|
|
214
|
+
onClick={(event) => (event.target as HTMLElement).closest('a') || navigate({ to: chartUrl(row) })}
|
|
215
|
+
>
|
|
216
|
+
{columns.map((column) => (
|
|
217
|
+
<TableCell key={column.header}>{column.render ? column.render(row) : column.text(row)}</TableCell>
|
|
218
|
+
))}
|
|
219
|
+
</TableRow>
|
|
220
|
+
))}
|
|
221
|
+
</TableBody>
|
|
222
|
+
</Table>
|
|
223
|
+
) : (
|
|
224
|
+
<p className={styles.message}>{t('noRegistryMatches', 'No patients match these filters.')}</p>
|
|
225
|
+
)}
|
|
226
|
+
{shown.length > 0 && (
|
|
227
|
+
<Pagination
|
|
228
|
+
page={currentPage}
|
|
229
|
+
pageSize={pageSize}
|
|
230
|
+
pageSizes={pageSizes}
|
|
231
|
+
totalItems={shown.length}
|
|
232
|
+
onChange={({ page, pageSize: size }) => {
|
|
233
|
+
setPageSize(size);
|
|
234
|
+
goTo(page);
|
|
235
|
+
}}
|
|
236
|
+
/>
|
|
237
|
+
)}
|
|
238
|
+
</>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export default function Registry() {
|
|
243
|
+
const { t } = useTranslation();
|
|
244
|
+
const canSeeRegistry = useScreenAccess('registry');
|
|
245
|
+
|
|
246
|
+
if (!canSeeRegistry) {
|
|
247
|
+
return <p className={styles.message}>{t('noAccessToRegistry', 'You do not have access to the registry.')}</p>;
|
|
248
|
+
}
|
|
249
|
+
return (
|
|
250
|
+
<div className={styles.registry}>
|
|
251
|
+
<h1 className={styles.title}>{t('registry', 'Registry')}</h1>
|
|
252
|
+
<RegistryTable />
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Rows shaped as the RHD Patient List report returns them. */
|
|
2
|
+
export const registryRows = Array.from({ length: 30 }, (_, i) => ({
|
|
3
|
+
rhd_id: `rhd${String(i + 1).padStart(5, '0')}`,
|
|
4
|
+
full_name: `Patient ${i + 1}`,
|
|
5
|
+
sex: i % 2 ? 'M' : 'F',
|
|
6
|
+
age_years: 10 + i,
|
|
7
|
+
enrollment_status: i === 3 ? 'Completed' : 'Active',
|
|
8
|
+
diagnosis_category: ['RHD A', 'RHD B', 'RHD C'][i % 3],
|
|
9
|
+
prophylaxis_regimen: i === 1 ? 'Oral penicillin V' : 'Q28 day BPG',
|
|
10
|
+
next_consultation_date: i === 0 ? '2026-10-15T00:00:00.000+0000' : null,
|
|
11
|
+
cardiac_clinic: i % 2 ? 'Gulu RRH' : 'Lira RRH',
|
|
12
|
+
primary_care_clinic: i % 2 ? 'Anyeke HCIV' : null,
|
|
13
|
+
patient_uuid: `patient-${i + 1}`,
|
|
14
|
+
}));
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
@use '@carbon/colors';
|
|
2
|
+
@use '@carbon/layout';
|
|
3
|
+
@use '@carbon/type';
|
|
4
|
+
|
|
5
|
+
.registry {
|
|
6
|
+
padding: layout.$spacing-07 layout.$spacing-07 layout.$spacing-09;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.title {
|
|
10
|
+
@include type.type-style('heading-05');
|
|
11
|
+
margin-bottom: layout.$spacing-06;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.row {
|
|
15
|
+
cursor: pointer;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.message {
|
|
19
|
+
@include type.type-style('body-01');
|
|
20
|
+
padding: layout.$spacing-05;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.filters {
|
|
24
|
+
display: grid;
|
|
25
|
+
grid-template-columns: 2fr repeat(4, 1fr);
|
|
26
|
+
gap: layout.$spacing-05;
|
|
27
|
+
align-items: end;
|
|
28
|
+
margin-bottom: layout.$spacing-05;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Carbon has no orange tag, so missing data flags are drawn in Carbon's orange here.
|
|
32
|
+
.dataQuality {
|
|
33
|
+
background-color: colors.$orange-10;
|
|
34
|
+
color: colors.$orange-70;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.actions {
|
|
38
|
+
display: flex;
|
|
39
|
+
justify-content: flex-end;
|
|
40
|
+
margin-bottom: layout.$spacing-05;
|
|
41
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
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 { navigate } from '@openmrs/esm-framework';
|
|
6
|
+
import { signInWith } from '../access/sign-in.test-helper';
|
|
7
|
+
import { useReportDataset } from '../reports/report-dataset.resource';
|
|
8
|
+
import { registryRows } from './registry.fixture';
|
|
9
|
+
import Registry from './registry.component';
|
|
10
|
+
|
|
11
|
+
vi.mock('../reports/report-dataset.resource', () => ({ useReportDataset: vi.fn() }));
|
|
12
|
+
const mockUseReportDataset = vi.mocked(useReportDataset);
|
|
13
|
+
|
|
14
|
+
function dataset(value: Partial<ReturnType<typeof useReportDataset>>) {
|
|
15
|
+
mockUseReportDataset.mockReturnValue({ columns: [], rows: [], isLoading: false, error: undefined, ...value });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const registryPrivilege = 'View Patient Flags';
|
|
19
|
+
|
|
20
|
+
describe('Registry', () => {
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
window.getOpenmrsSpaBase = () => '/openmrs/spa/';
|
|
23
|
+
await signInWith([registryPrivilege]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("lists the report's patients with their name, ACT ID, age, sex, diagnosis, regimen and next consultation", () => {
|
|
27
|
+
dataset({ rows: registryRows });
|
|
28
|
+
|
|
29
|
+
render(<Registry />);
|
|
30
|
+
|
|
31
|
+
const headers = screen.getAllByRole('columnheader').map((header) => header.textContent);
|
|
32
|
+
expect(headers).toEqual([
|
|
33
|
+
'Name',
|
|
34
|
+
'ACT ID',
|
|
35
|
+
'Age',
|
|
36
|
+
'Sex',
|
|
37
|
+
'Diagnosis category',
|
|
38
|
+
'Prophylaxis regimen',
|
|
39
|
+
'Next consultation',
|
|
40
|
+
'Flags',
|
|
41
|
+
]);
|
|
42
|
+
const first = screen.getByRole('row', { name: /Patient 1\b/ });
|
|
43
|
+
expect(
|
|
44
|
+
within(first)
|
|
45
|
+
.getAllByRole('cell')
|
|
46
|
+
.map((cell) => cell.textContent),
|
|
47
|
+
).toEqual(['Patient 1', 'rhd00001', '10', 'F', 'RHD A', 'Q28 day BPG', '15-Oct-2026', '']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('evaluates the configured report over every enrolment up to today', () => {
|
|
51
|
+
vi.useFakeTimers({ now: new Date(2026, 8, 29, 10) });
|
|
52
|
+
dataset({ rows: registryRows });
|
|
53
|
+
|
|
54
|
+
render(<Registry />);
|
|
55
|
+
|
|
56
|
+
vi.useRealTimers();
|
|
57
|
+
expect(mockUseReportDataset).toHaveBeenLastCalledWith('f1a2b3c4-d5e6-7890-abcd-ef1234567890', {
|
|
58
|
+
startDate: '1900-01-01',
|
|
59
|
+
endDate: '2026-09-29',
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('shows the patients a page at a time', async () => {
|
|
64
|
+
dataset({ rows: registryRows });
|
|
65
|
+
|
|
66
|
+
render(<Registry />);
|
|
67
|
+
|
|
68
|
+
expect(screen.getAllByRole('row')).toHaveLength(1 + 25);
|
|
69
|
+
await userEvent.click(screen.getByRole('button', { name: /next page/i }));
|
|
70
|
+
expect(screen.getAllByRole('row')).toHaveLength(1 + 5);
|
|
71
|
+
expect(screen.getByRole('row', { name: /Patient 30\b/ })).toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("opens a patient's chart from their row", async () => {
|
|
75
|
+
dataset({ rows: registryRows });
|
|
76
|
+
|
|
77
|
+
render(<Registry />);
|
|
78
|
+
|
|
79
|
+
await userEvent.click(screen.getByRole('row', { name: /Patient 2\b/ }));
|
|
80
|
+
expect(vi.mocked(navigate)).toHaveBeenCalledWith({ to: '${openmrsSpaBase}/patient/patient-2/chart' });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('offers larger pages, and shows as many rows as the page size chosen', async () => {
|
|
84
|
+
dataset({ rows: registryRows });
|
|
85
|
+
|
|
86
|
+
render(<Registry />);
|
|
87
|
+
|
|
88
|
+
await userEvent.selectOptions(screen.getByLabelText(/items per page/i), '50');
|
|
89
|
+
expect(screen.getAllByRole('row')).toHaveLength(1 + 30);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("opens a patient's chart once when their name is clicked", async () => {
|
|
93
|
+
dataset({ rows: registryRows });
|
|
94
|
+
|
|
95
|
+
render(<Registry />);
|
|
96
|
+
|
|
97
|
+
await userEvent.click(screen.getByRole('link', { name: 'Patient 2' }));
|
|
98
|
+
expect(vi.mocked(navigate)).toHaveBeenCalledTimes(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("links each patient's name to their chart, for keyboard and new-tab use", () => {
|
|
102
|
+
dataset({ rows: registryRows });
|
|
103
|
+
|
|
104
|
+
render(<Registry />);
|
|
105
|
+
|
|
106
|
+
expect(screen.getByRole('link', { name: 'Patient 2' })).toHaveAttribute(
|
|
107
|
+
'href',
|
|
108
|
+
'/openmrs/spa/patient/patient-2/chart',
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('says so when no patient is enrolled', () => {
|
|
113
|
+
dataset({ rows: [] });
|
|
114
|
+
|
|
115
|
+
render(<Registry />);
|
|
116
|
+
|
|
117
|
+
expect(screen.getByText('No patients are enrolled in the registry.')).toBeInTheDocument();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('says so when the report cannot be loaded', () => {
|
|
121
|
+
dataset({ error: new Error('Server responded with 404') });
|
|
122
|
+
|
|
123
|
+
render(<Registry />);
|
|
124
|
+
|
|
125
|
+
expect(screen.getByText('Could not load the registry')).toBeInTheDocument();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('shows a placeholder while the report runs', () => {
|
|
129
|
+
dataset({ isLoading: true });
|
|
130
|
+
|
|
131
|
+
render(<Registry />);
|
|
132
|
+
|
|
133
|
+
expect(screen.getByTestId('registry-loading')).toBeInTheDocument();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('tells a user without the registry privilege that they cannot see it', async () => {
|
|
137
|
+
await signInWith(['Get Patients'], {
|
|
138
|
+
screenPrivileges: { home: 'x', registry: 'App: act.registry', waitingList: 'x', screenPositive: 'x' },
|
|
139
|
+
});
|
|
140
|
+
dataset({ rows: registryRows });
|
|
141
|
+
|
|
142
|
+
render(<Registry />);
|
|
143
|
+
|
|
144
|
+
expect(screen.getByText('You do not have access to the registry.')).toBeInTheDocument();
|
|
145
|
+
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
|
146
|
+
});
|
|
147
|
+
});
|