@pih/esm-audit-app 2.1.1-pre.144 → 2.1.1-pre.151
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/.turbo/turbo-build.log +1 -1
- package/README.md +96 -2
- package/dist/115.js +1 -1
- package/dist/117.js +1 -1
- package/dist/117.js.map +1 -1
- package/dist/508.js +1 -1
- package/dist/508.js.map +1 -1
- package/dist/514.js +1 -0
- package/dist/514.js.map +1 -0
- package/dist/912.js +1 -1
- package/dist/912.js.map +1 -1
- package/dist/main.js +1 -1
- package/dist/pih-esm-audit-app.js +1 -1
- package/dist/pih-esm-audit-app.js.buildmanifest.json +34 -34
- package/dist/routes.json +1 -1
- package/locales/en/translation.json +118 -0
- package/locales/fr/translation.json +118 -0
- package/package.json +1 -1
- package/src/audit/audit-search.component.tsx +58 -0
- package/src/audit/audit.component.tsx +63 -16
- package/src/audit/audit.resource.ts +445 -10
- package/src/audit/audit.scss +6 -0
- package/src/audit/date-range-picker.test-stub.tsx +40 -0
- package/src/audit/date-range.test.ts +128 -0
- package/src/audit/date-range.ts +120 -0
- package/src/audit/encounter-filters.component.tsx +40 -18
- package/src/audit/encounter-filters.test.ts +1 -2
- package/src/audit/encounter-filters.ts +2 -27
- package/src/audit/patient-record.component.test.tsx +3 -5
- package/src/audit/patient-record.component.tsx +13 -2
- package/src/audit/patient-search.component.tsx +1 -1
- package/src/audit/provider-encounters.component.test.tsx +254 -0
- package/src/audit/provider-encounters.component.tsx +160 -0
- package/src/audit/provider-search.component.test.tsx +72 -0
- package/src/audit/provider-search.component.tsx +116 -0
- package/src/audit/user-encounters.component.test.tsx +431 -0
- package/src/audit/user-encounters.component.tsx +239 -0
- package/src/audit/user-encounters.test.ts +176 -0
- package/src/audit/user-encounters.ts +166 -0
- package/src/audit/user-search.component.test.tsx +89 -0
- package/src/audit/user-search.component.tsx +111 -0
- package/src/root.component.test.tsx +48 -3
- package/src/types.ts +34 -0
- package/translations/en.json +29 -0
- package/translations/es.json +29 -0
- package/translations/fr.json +29 -0
- package/dist/446.js +0 -1
- package/dist/446.js.map +0 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A range of calendar dates, held as `YYYY-MM-DD` rather than as instants because that is what
|
|
3
|
+
* someone picking a date means. Both ends are inclusive.
|
|
4
|
+
*/
|
|
5
|
+
export interface DateRange {
|
|
6
|
+
fromDate?: string;
|
|
7
|
+
toDate?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function pad(value: number): string {
|
|
11
|
+
return String(value).padStart(2, '0');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The calendar date a `Date` falls on, in the browser's time zone. */
|
|
15
|
+
export function toDateKey(date: Date | null | undefined): string | undefined {
|
|
16
|
+
if (!date || Number.isNaN(date.getTime())) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Local midnight on a calendar date, for handing a stored range back to a date picker. */
|
|
23
|
+
export function fromDateKey(key: string | undefined): Date | null {
|
|
24
|
+
if (!key) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const [year, month, day] = key.split('-').map(Number);
|
|
28
|
+
if (!year || !month || !day) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return new Date(year, month - 1, day);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The part of a react-aria date value `fromPickerRange` reads, whatever calendar it is in. */
|
|
35
|
+
interface PickerDate {
|
|
36
|
+
toDate(timeZone: string): Date;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The earliest year the picker accepts; it is the framework's floor for a date picker with no
|
|
41
|
+
* `minDate`, which the picker flags as out of range but reports all the same.
|
|
42
|
+
*/
|
|
43
|
+
const EARLIEST_PICKER_YEAR = 1793;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The range an `OpenmrsDateRangePicker` reports through `onChangeRaw`.
|
|
47
|
+
*
|
|
48
|
+
* Its `onChange` cannot be used: react-aria reports `null` until both ends of the range are
|
|
49
|
+
* complete, which is on every keystroke while typing the first date, and the framework's `onChange`
|
|
50
|
+
* reads `start` off that `null` and throws. An incomplete range is treated as no range at all.
|
|
51
|
+
*
|
|
52
|
+
* So is one the picker would reject. Once the first date is in, react-aria reports a complete range
|
|
53
|
+
* on every keystroke of the last year typed, as years 2, 20 and 202 on the way to 2026, and those
|
|
54
|
+
* are no more the range someone means than a half-typed one is.
|
|
55
|
+
*/
|
|
56
|
+
export function fromPickerRange(
|
|
57
|
+
range: { start?: PickerDate | null; end?: PickerDate | null } | null | undefined,
|
|
58
|
+
): DateRange {
|
|
59
|
+
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
60
|
+
const start = range?.start?.toDate(timeZone);
|
|
61
|
+
const end = range?.end?.toDate(timeZone);
|
|
62
|
+
if (!start || !end || start > end || start.getFullYear() < EARLIEST_PICKER_YEAR) {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
return { fromDate: toDateKey(start), toDate: toDateKey(end) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What to seed an `OpenmrsDateRangePicker` with, as its `defaultValue`: nothing at all when no range
|
|
70
|
+
* is set.
|
|
71
|
+
*
|
|
72
|
+
* The picker must be left uncontrolled. React-aria keeps a half-typed range to one side, and only
|
|
73
|
+
* shows it while the picker's value is empty; the framework hands an empty `value` on as a range with
|
|
74
|
+
* two empty ends rather than as no range, which hides it, so the day and month typed so far are
|
|
75
|
+
* wiped the moment the year completes the first date. Remount the picker (with a `key`) to clear it.
|
|
76
|
+
*/
|
|
77
|
+
export function toPickerDefault(range: DateRange): [Date | null, Date | null] | undefined {
|
|
78
|
+
return hasDateRange(range) ? [fromDateKey(range.fromDate), fromDateKey(range.toDate)] : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function hasDateRange(range: DateRange): boolean {
|
|
82
|
+
return Boolean(range.fromDate || range.toDate);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What each pihapps audit endpoint calls its date bounds. Both take a separate range per column,
|
|
87
|
+
* so a caller names the one it means: the obs streams bound the action each searched on, and the
|
|
88
|
+
* provider view is asking when encounters happened, hence `encounterDatetime`.
|
|
89
|
+
*/
|
|
90
|
+
export const OBS_CREATED_DATE_PARAMS = { from: 'createdOnOrAfter', to: 'createdOnOrBefore' } as const;
|
|
91
|
+
export const OBS_VOIDED_DATE_PARAMS = { from: 'voidedOnOrAfter', to: 'voidedOnOrBefore' } as const;
|
|
92
|
+
export const ENCOUNTER_DATE_PARAMS = {
|
|
93
|
+
from: 'encounterDatetimeOnOrAfter',
|
|
94
|
+
to: 'encounterDatetimeOnOrBefore',
|
|
95
|
+
} as const;
|
|
96
|
+
|
|
97
|
+
interface DateParamNames {
|
|
98
|
+
from: string;
|
|
99
|
+
to: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The date query one of those endpoints understands. `params` names its two bounds; there is no
|
|
104
|
+
* default, since no two of them agree any more.
|
|
105
|
+
*
|
|
106
|
+
* Bare dates are sent deliberately: those endpoints read a date-only upper bound as the whole of
|
|
107
|
+
* that day, so there is no need to spell out a time to avoid an inclusive bound matching only
|
|
108
|
+
* midnight. They read both ends against whatever the search is about: the audit action where one is
|
|
109
|
+
* named, and the encounter's own datetime for a provider search.
|
|
110
|
+
*/
|
|
111
|
+
export function buildAuditDateQuery(range: DateRange, params: DateParamNames): string {
|
|
112
|
+
let query = '';
|
|
113
|
+
if (range.fromDate) {
|
|
114
|
+
query += `&${params.from}=${range.fromDate}`;
|
|
115
|
+
}
|
|
116
|
+
if (range.toDate) {
|
|
117
|
+
query += `&${params.to}=${range.toDate}`;
|
|
118
|
+
}
|
|
119
|
+
return query;
|
|
120
|
+
}
|
|
@@ -1,26 +1,34 @@
|
|
|
1
|
-
import React, { useMemo } from 'react';
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
3
|
import { Button, ComboBox } from '@carbon/react';
|
|
4
4
|
import { OpenmrsDateRangePicker } from '@openmrs/esm-framework';
|
|
5
|
-
import { type
|
|
6
|
-
import {
|
|
7
|
-
import { type EncounterFilters,
|
|
5
|
+
import { type OpenmrsResourceRef } from '../types';
|
|
6
|
+
import { fromPickerRange, toPickerDefault } from './date-range';
|
|
7
|
+
import { type EncounterFilters, hasActiveFilters } from './encounter-filters';
|
|
8
8
|
import styles from './audit.scss';
|
|
9
9
|
|
|
10
10
|
interface EncounterFiltersBarProps {
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* The types to offer. Supplied rather than fetched, because the right set depends on the list
|
|
13
|
+
* being filtered: a patient's own types where they can be known, every type in the system where
|
|
14
|
+
* they cannot.
|
|
15
|
+
*/
|
|
16
|
+
encounterTypes: Array<OpenmrsResourceRef>;
|
|
17
|
+
isLoadingTypes: boolean;
|
|
13
18
|
filters: EncounterFilters;
|
|
14
19
|
onChange(filters: EncounterFilters): void;
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
/**
|
|
18
|
-
* Narrows
|
|
19
|
-
* The types on offer are the ones this patient's encounters actually use.
|
|
23
|
+
* Narrows an encounter list by encounter type and by the date the encounter happened.
|
|
20
24
|
*/
|
|
21
|
-
export default function EncounterFiltersBar({
|
|
25
|
+
export default function EncounterFiltersBar({
|
|
26
|
+
encounterTypes,
|
|
27
|
+
isLoadingTypes,
|
|
28
|
+
filters,
|
|
29
|
+
onChange,
|
|
30
|
+
}: EncounterFiltersBarProps) {
|
|
22
31
|
const { t } = useTranslation();
|
|
23
|
-
const { encounterTypes, isLoading } = usePatientEncounterTypes(patient, includeDeleted);
|
|
24
32
|
|
|
25
33
|
const today = useMemo(() => new Date(), []);
|
|
26
34
|
|
|
@@ -36,16 +44,14 @@ export default function EncounterFiltersBar({ patient, includeDeleted, filters,
|
|
|
36
44
|
return encounterTypes;
|
|
37
45
|
}, [encounterTypes, filters.encounterType]);
|
|
38
46
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
[filters.fromDate, filters.toDate],
|
|
42
|
-
);
|
|
47
|
+
// bumped to clear the range picker, which has to be left to hold its own value
|
|
48
|
+
const [datePickerKey, setDatePickerKey] = useState(0);
|
|
43
49
|
|
|
44
50
|
return (
|
|
45
51
|
<div className={styles.filters}>
|
|
46
52
|
<ComboBox
|
|
47
53
|
className={styles.filterControl}
|
|
48
|
-
disabled={
|
|
54
|
+
disabled={isLoadingTypes || items.length === 0}
|
|
49
55
|
id="encounter-type-filter"
|
|
50
56
|
items={items}
|
|
51
57
|
itemToString={(encounterType: OpenmrsResourceRef | null) => encounterType?.display ?? ''}
|
|
@@ -59,14 +65,30 @@ export default function EncounterFiltersBar({ patient, includeDeleted, filters,
|
|
|
59
65
|
/>
|
|
60
66
|
<OpenmrsDateRangePicker
|
|
61
67
|
className={styles.filterControl}
|
|
68
|
+
defaultValue={toPickerDefault(filters)}
|
|
69
|
+
id="encounter-date-range-filter"
|
|
62
70
|
labelText={t('encounterDateRange', 'Encounter date range')}
|
|
63
71
|
maxDate={today}
|
|
64
|
-
|
|
72
|
+
onChangeRaw={(range) => {
|
|
73
|
+
const { fromDate, toDate } = fromPickerRange(range);
|
|
74
|
+
// the picker reports on keystrokes that leave the range as it was, and a new filters object
|
|
75
|
+
// would send the list back to its first page for nothing
|
|
76
|
+
if (fromDate !== filters.fromDate || toDate !== filters.toDate) {
|
|
77
|
+
onChange({ ...filters, fromDate, toDate });
|
|
78
|
+
}
|
|
79
|
+
}}
|
|
80
|
+
key={datePickerKey}
|
|
65
81
|
size="sm"
|
|
66
|
-
value={dateRange}
|
|
67
82
|
/>
|
|
68
83
|
{hasActiveFilters(filters) ? (
|
|
69
|
-
<Button
|
|
84
|
+
<Button
|
|
85
|
+
className={styles.clearFilters}
|
|
86
|
+
kind="ghost"
|
|
87
|
+
onClick={() => {
|
|
88
|
+
setDatePickerKey((key) => key + 1);
|
|
89
|
+
onChange({});
|
|
90
|
+
}}
|
|
91
|
+
size="sm">
|
|
70
92
|
{t('clearFilters', 'Clear filters')}
|
|
71
93
|
</Button>
|
|
72
94
|
) : null}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { type AuditEncounter } from '../types';
|
|
2
|
+
import { fromDateKey, toDateKey } from './date-range';
|
|
2
3
|
import {
|
|
3
4
|
buildEncounterFilterQuery,
|
|
4
5
|
distinctEncounterTypes,
|
|
5
|
-
fromDateKey,
|
|
6
6
|
hasActiveFilters,
|
|
7
7
|
matchesEncounterFilters,
|
|
8
|
-
toDateKey,
|
|
9
8
|
} from './encounter-filters';
|
|
10
9
|
|
|
11
10
|
const consultationType = { uuid: 'type-consultation', display: 'Oncology Consultation' };
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
import { type AuditEncounter, type OpenmrsResourceRef } from '../types';
|
|
2
|
+
import { type DateRange, toDateKey } from './date-range';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The filters an auditor can narrow a patient's encounter list by. Dates are held as calendar
|
|
5
6
|
* dates (`YYYY-MM-DD`) rather than instants, because that is what someone picking a date means,
|
|
6
7
|
* and both bounds are inclusive.
|
|
7
8
|
*/
|
|
8
|
-
export interface EncounterFilters {
|
|
9
|
+
export interface EncounterFilters extends DateRange {
|
|
9
10
|
/**
|
|
10
11
|
* The whole reference rather than just the uuid, so that the chosen type stays displayable even
|
|
11
12
|
* if it drops out of the available options — which happens when deleted encounters are hidden
|
|
12
13
|
* again and the type only occurred on those.
|
|
13
14
|
*/
|
|
14
15
|
encounterType?: OpenmrsResourceRef;
|
|
15
|
-
fromDate?: string;
|
|
16
|
-
toDate?: string;
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
/**
|
|
@@ -31,30 +30,6 @@ export function distinctEncounterTypes(encounters: Array<AuditEncounter>): Array
|
|
|
31
30
|
return Array.from(byUuid.values()).sort((a, b) => (a.display ?? '').localeCompare(b.display ?? ''));
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
function pad(value: number): string {
|
|
35
|
-
return String(value).padStart(2, '0');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** The calendar date a `Date` falls on, in the browser's time zone. */
|
|
39
|
-
export function toDateKey(date: Date | null | undefined): string | undefined {
|
|
40
|
-
if (!date || Number.isNaN(date.getTime())) {
|
|
41
|
-
return undefined;
|
|
42
|
-
}
|
|
43
|
-
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Local midnight on a calendar date, for handing a stored filter back to the date picker. */
|
|
47
|
-
export function fromDateKey(key: string | undefined): Date | null {
|
|
48
|
-
if (!key) {
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
const [year, month, day] = key.split('-').map(Number);
|
|
52
|
-
if (!year || !month || !day) {
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
return new Date(year, month - 1, day);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
33
|
export function hasActiveFilters(filters: EncounterFilters): boolean {
|
|
59
34
|
return Boolean(filters.encounterType || filters.fromDate || filters.toDate);
|
|
60
35
|
}
|
|
@@ -2,12 +2,12 @@ import React from 'react';
|
|
|
2
2
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
3
3
|
import userEvent from '@testing-library/user-event';
|
|
4
4
|
import { SWRConfig } from 'swr';
|
|
5
|
-
import { openmrsFetch
|
|
5
|
+
import { openmrsFetch } from '@openmrs/esm-framework';
|
|
6
6
|
import { type AuditEncounter, type AuditPatient } from '../types';
|
|
7
7
|
import PatientRecord from './patient-record.component';
|
|
8
|
+
import { stubDateRangePicker } from './date-range-picker.test-stub';
|
|
8
9
|
|
|
9
10
|
const mockOpenmrsFetch = jest.mocked(openmrsFetch);
|
|
10
|
-
const mockUseConfig = jest.mocked(useConfig);
|
|
11
11
|
|
|
12
12
|
const mockPatient: AuditPatient = {
|
|
13
13
|
uuid: 'patient-1',
|
|
@@ -93,9 +93,7 @@ function renderPatientRecord() {
|
|
|
93
93
|
|
|
94
94
|
describe('<PatientRecord />', () => {
|
|
95
95
|
beforeEach(() => {
|
|
96
|
-
|
|
97
|
-
patientChartUrl: '${openmrsSpaBase}/patient/${patientUuid}/chart',
|
|
98
|
-
});
|
|
96
|
+
stubDateRangePicker();
|
|
99
97
|
mockRestApi();
|
|
100
98
|
});
|
|
101
99
|
|
|
@@ -2,7 +2,12 @@ import React, { useState } from 'react';
|
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
3
|
import { Button, Checkbox, InlineNotification, Tab, TabList, TabPanel, TabPanels, Tabs } from '@carbon/react';
|
|
4
4
|
import { ArrowLeftIcon, ErrorState } from '@openmrs/esm-framework';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
canListDeletedEncounters,
|
|
7
|
+
getPreferredIdentifier,
|
|
8
|
+
useAuditPatient,
|
|
9
|
+
usePatientEncounterTypes,
|
|
10
|
+
} from './audit.resource';
|
|
6
11
|
import { type EncounterFilters } from './encounter-filters';
|
|
7
12
|
import EncounterFiltersBar from './encounter-filters.component';
|
|
8
13
|
import PatientActivity from './patient-activity.component';
|
|
@@ -38,6 +43,7 @@ export default function PatientRecord({
|
|
|
38
43
|
const [filters, setFilters] = useState<EncounterFilters>({});
|
|
39
44
|
|
|
40
45
|
const { patient, error: patientError, isLoading: isLoadingPatient } = useAuditPatient(patientUuid);
|
|
46
|
+
const { encounterTypes, isLoading: isLoadingTypes } = usePatientEncounterTypes(patient, includeDeleted);
|
|
41
47
|
const cannotIncludeDeleted = includeDeleted && Boolean(patient) && !canListDeletedEncounters(patient);
|
|
42
48
|
|
|
43
49
|
return (
|
|
@@ -54,7 +60,12 @@ export default function PatientRecord({
|
|
|
54
60
|
</span>
|
|
55
61
|
</div>
|
|
56
62
|
|
|
57
|
-
<EncounterFiltersBar
|
|
63
|
+
<EncounterFiltersBar
|
|
64
|
+
encounterTypes={encounterTypes}
|
|
65
|
+
filters={filters}
|
|
66
|
+
isLoadingTypes={isLoadingTypes}
|
|
67
|
+
onChange={setFilters}
|
|
68
|
+
/>
|
|
58
69
|
|
|
59
70
|
<div className={styles.toolbar}>
|
|
60
71
|
<Checkbox
|
|
@@ -42,7 +42,7 @@ export default function PatientSearch({ onSelectPatient }: PatientSearchProps) {
|
|
|
42
42
|
}, [debouncedSearchTerm]);
|
|
43
43
|
|
|
44
44
|
return (
|
|
45
|
-
<div
|
|
45
|
+
<div>
|
|
46
46
|
<Search
|
|
47
47
|
className={styles.searchInput}
|
|
48
48
|
labelText={t('searchForPatient', 'Search for a patient by name or identifier')}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { SWRConfig } from 'swr';
|
|
5
|
+
import { openmrsFetch, useConfig } from '@openmrs/esm-framework';
|
|
6
|
+
import { type AuditEncounter } from '../types';
|
|
7
|
+
import ProviderEncounters from './provider-encounters.component';
|
|
8
|
+
import { stubDateRangePicker } from './date-range-picker.test-stub';
|
|
9
|
+
|
|
10
|
+
const mockOpenmrsFetch = jest.mocked(openmrsFetch);
|
|
11
|
+
const mockUseConfig = jest.mocked(useConfig);
|
|
12
|
+
|
|
13
|
+
const provider = {
|
|
14
|
+
uuid: 'prov-1',
|
|
15
|
+
display: 'MCPRPG - Louidor Jean paul',
|
|
16
|
+
identifier: 'MCPRPG',
|
|
17
|
+
person: { uuid: 'person-1', display: 'Louidor Jean paul' },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What the pihapps audit endpoint answers: whole encounters, paged, including the auditInfo that
|
|
22
|
+
* names who entered each one. No second read per row, and no FHIR.
|
|
23
|
+
*/
|
|
24
|
+
const encounters: Array<AuditEncounter> = [
|
|
25
|
+
{
|
|
26
|
+
uuid: 'enc-1',
|
|
27
|
+
encounterDatetime: '2026-04-18T09:00:00.000+0000',
|
|
28
|
+
patient: { uuid: 'patient-1', display: 'Y2AHXV - Dave TestPatient' },
|
|
29
|
+
encounterType: { uuid: 'type-1', display: 'Oncology Consultation' },
|
|
30
|
+
form: { uuid: 'form-1', display: 'Oncology Consult Note' },
|
|
31
|
+
location: { uuid: 'loc-1', display: 'Klinik Ekstèn' },
|
|
32
|
+
auditInfo: { creator: { uuid: 'user-1', display: 'Cos John' }, dateCreated: '2026-04-18T09:05:00.000+0000' },
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
uuid: 'enc-2',
|
|
36
|
+
voided: true,
|
|
37
|
+
encounterDatetime: '2026-04-11T08:00:00.000+0000',
|
|
38
|
+
patient: { uuid: 'patient-2', display: 'Y2BQRS - Jean Baptiste' },
|
|
39
|
+
encounterType: { uuid: 'type-2', display: 'Inscription' },
|
|
40
|
+
auditInfo: { creator: { uuid: 'user-2', display: 'data.clerk' }, dateCreated: '2026-04-11T08:02:00.000+0000' },
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const encounterTypeCounts = [
|
|
45
|
+
{ encounterType: { uuid: 'type-2', display: 'Inscription' }, count: 12 },
|
|
46
|
+
{ encounterType: { uuid: 'type-3', display: 'Never Recorded' }, count: 0 },
|
|
47
|
+
{ encounterType: { uuid: 'type-1', display: 'Oncology Consultation' }, count: 30 },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
function mockRestApi({ results = encounters } = {}) {
|
|
51
|
+
mockOpenmrsFetch.mockImplementation((url: string) => {
|
|
52
|
+
if (url.includes('/pihapps/encounterTypeCount')) {
|
|
53
|
+
// the endpoint leaves out unused types only when asked, so the mock does too
|
|
54
|
+
const results = url.includes('onlyUsed=true')
|
|
55
|
+
? encounterTypeCounts.filter((entry) => entry.count > 0)
|
|
56
|
+
: encounterTypeCounts;
|
|
57
|
+
return Promise.resolve({ data: { results } }) as ReturnType<typeof openmrsFetch>;
|
|
58
|
+
}
|
|
59
|
+
if (url.includes('/pihapps/encounter?')) {
|
|
60
|
+
return Promise.resolve({ data: { results, totalCount: results.length } }) as ReturnType<typeof openmrsFetch>;
|
|
61
|
+
}
|
|
62
|
+
return Promise.resolve({ data: provider }) as ReturnType<typeof openmrsFetch>;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderProviderEncounters() {
|
|
67
|
+
const onSelectEncounter = jest.fn();
|
|
68
|
+
const onBackToSearch = jest.fn();
|
|
69
|
+
render(
|
|
70
|
+
<SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
|
|
71
|
+
<ProviderEncounters onBackToSearch={onBackToSearch} onSelectEncounter={onSelectEncounter} providerUuid="prov-1" />
|
|
72
|
+
</SWRConfig>,
|
|
73
|
+
);
|
|
74
|
+
return { onSelectEncounter, onBackToSearch };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('<ProviderEncounters />', () => {
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
stubDateRangePicker();
|
|
80
|
+
mockUseConfig.mockReturnValue({ encountersPageSize: 10 });
|
|
81
|
+
mockRestApi();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The most recent audit search, decoded — `useOpenmrsPagination` rebuilds the url through `URL`.
|
|
86
|
+
* The most recent one matters because a filter change issues another.
|
|
87
|
+
*/
|
|
88
|
+
function auditSearchUrl() {
|
|
89
|
+
const urls = mockOpenmrsFetch.mock.calls
|
|
90
|
+
.map(([url]) => url as string)
|
|
91
|
+
.filter((url) => url.includes('/pihapps/encounter?'));
|
|
92
|
+
return decodeURIComponent(urls[urls.length - 1] ?? '');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
it('asks the pihapps audit endpoint which encounters name the provider', async () => {
|
|
96
|
+
renderProviderEncounters();
|
|
97
|
+
|
|
98
|
+
await screen.findByRole('cell', { name: 'Oncology Consultation' });
|
|
99
|
+
expect(auditSearchUrl()).toContain('/ws/rest/v1/pihapps/encounter?provider=prov-1');
|
|
100
|
+
expect(auditSearchUrl()).toContain('&limit=10&startIndex=0&totalCount=true');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The endpoint leaves voided encounters out unless asked, so an audit has to ask: without this
|
|
105
|
+
* the table would quietly stop showing what had been deleted.
|
|
106
|
+
*/
|
|
107
|
+
it('asks for voided encounters to be included', async () => {
|
|
108
|
+
renderProviderEncounters();
|
|
109
|
+
|
|
110
|
+
await screen.findByRole('cell', { name: 'Oncology Consultation' });
|
|
111
|
+
expect(auditSearchUrl()).toContain('includeVoided=true');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The endpoint sorts nothing unless asked, so a page without an ordering could repeat or skip
|
|
116
|
+
* rows. A provider search names no audit action, hence the encounter's own datetime.
|
|
117
|
+
*/
|
|
118
|
+
it('asks for the ordering a provider search wants', async () => {
|
|
119
|
+
renderProviderEncounters();
|
|
120
|
+
|
|
121
|
+
await screen.findByRole('cell', { name: 'Oncology Consultation' });
|
|
122
|
+
expect(auditSearchUrl()).toContain('sortBy=encounterDatetime-desc&sortBy=encounterId-desc');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* One paged request per page: the endpoint returns whole encounters with their auditInfo, so
|
|
127
|
+
* nothing further is read per row.
|
|
128
|
+
*/
|
|
129
|
+
it('reads the page in a single request', async () => {
|
|
130
|
+
renderProviderEncounters();
|
|
131
|
+
|
|
132
|
+
await screen.findByRole('cell', { name: 'Oncology Consultation' });
|
|
133
|
+
expect(mockOpenmrsFetch.mock.calls.filter(([url]) => (url as string).includes('/pihapps/encounter?'))).toHaveLength(
|
|
134
|
+
1,
|
|
135
|
+
);
|
|
136
|
+
expect(mockOpenmrsFetch.mock.calls.some(([url]) => (url as string).includes('/encounter/'))).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('lists them across patients, naming who entered each one', async () => {
|
|
140
|
+
renderProviderEncounters();
|
|
141
|
+
|
|
142
|
+
// The skeleton renders rows of its own, so wait for real content before reading the table.
|
|
143
|
+
await screen.findByRole('cell', { name: 'Oncology Consultation' });
|
|
144
|
+
|
|
145
|
+
const firstRow = screen.getAllByRole('row').find((row) => row.textContent?.includes('Dave TestPatient'));
|
|
146
|
+
expect(firstRow).toHaveTextContent('Oncology Consultation');
|
|
147
|
+
expect(firstRow).toHaveTextContent('Klinik Ekstèn');
|
|
148
|
+
expect(firstRow).toHaveTextContent('Cos John');
|
|
149
|
+
|
|
150
|
+
const secondRow = screen.getAllByRole('row').find((row) => row.textContent?.includes('Jean Baptiste'));
|
|
151
|
+
expect(secondRow).toHaveTextContent('data.clerk');
|
|
152
|
+
expect(secondRow).toHaveTextContent('Deleted');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('is explicit that this is not the same as who changed the record', async () => {
|
|
156
|
+
renderProviderEncounters();
|
|
157
|
+
|
|
158
|
+
expect(await screen.findByText(/not the same as the ones they entered or changed/i)).toBeInTheDocument();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('offers only the encounter types something has been recorded against', async () => {
|
|
162
|
+
renderProviderEncounters();
|
|
163
|
+
await screen.findByRole('cell', { name: 'Oncology Consultation' });
|
|
164
|
+
|
|
165
|
+
await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
|
|
166
|
+
|
|
167
|
+
const options = within(screen.getByRole('listbox')).getAllByRole('option');
|
|
168
|
+
expect(options.map((option) => option.textContent)).toEqual(['Inscription', 'Oncology Consultation']);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('counts deleted encounters towards their type, as the rest of the audit does', async () => {
|
|
172
|
+
renderProviderEncounters();
|
|
173
|
+
await screen.findByRole('cell', { name: 'Oncology Consultation' });
|
|
174
|
+
|
|
175
|
+
const countUrls = mockOpenmrsFetch.mock.calls
|
|
176
|
+
.map(([url]) => String(url))
|
|
177
|
+
.filter((url) => url.includes('/pihapps/encounterTypeCount'));
|
|
178
|
+
expect(countUrls.length).toBeGreaterThan(0);
|
|
179
|
+
expect(countUrls.every((url) => url.includes('includeVoided=true'))).toBe(true);
|
|
180
|
+
expect(countUrls.every((url) => url.includes('onlyUsed=true'))).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('narrows the search on the server by encounter type', async () => {
|
|
184
|
+
renderProviderEncounters();
|
|
185
|
+
await screen.findByRole('cell', { name: 'Oncology Consultation' });
|
|
186
|
+
|
|
187
|
+
await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
|
|
188
|
+
await userEvent.click(await screen.findByRole('option', { name: 'Inscription' }));
|
|
189
|
+
|
|
190
|
+
await waitFor(() => expect(auditSearchUrl()).toContain('&encounterType=type-2'));
|
|
191
|
+
// the page goes back to the start, since a different filter is a different set of pages
|
|
192
|
+
expect(auditSearchUrl()).toContain('&startIndex=0');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('narrows the search on the server by encounter date', async () => {
|
|
196
|
+
renderProviderEncounters();
|
|
197
|
+
await screen.findByRole('cell', { name: 'Oncology Consultation' });
|
|
198
|
+
|
|
199
|
+
// the stubbed range picker reads its input as MM/DD/YYYY, hence the American-looking dates
|
|
200
|
+
await userEvent.clear(screen.getByLabelText('Encounter date range'));
|
|
201
|
+
await userEvent.type(screen.getByLabelText('Encounter date range'), '04/01/2026–04/30/2026');
|
|
202
|
+
|
|
203
|
+
await waitFor(() =>
|
|
204
|
+
expect(auditSearchUrl()).toContain(
|
|
205
|
+
'encounterDatetimeOnOrAfter=2026-04-01&encounterDatetimeOnOrBefore=2026-04-30',
|
|
206
|
+
),
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('keeps the search unbounded while only the start of a range has been typed', async () => {
|
|
211
|
+
renderProviderEncounters();
|
|
212
|
+
await screen.findByRole('cell', { name: 'Oncology Consultation' });
|
|
213
|
+
|
|
214
|
+
await userEvent.type(screen.getByLabelText('Encounter date range'), '04/01/2026–');
|
|
215
|
+
|
|
216
|
+
expect(screen.getByRole('cell', { name: 'Oncology Consultation' })).toBeInTheDocument();
|
|
217
|
+
expect(auditSearchUrl()).not.toContain('encounterDatetimeOnOrAfter');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('says so when nothing matches the filters', async () => {
|
|
221
|
+
renderProviderEncounters();
|
|
222
|
+
await screen.findByRole('cell', { name: 'Oncology Consultation' });
|
|
223
|
+
|
|
224
|
+
mockRestApi({ results: [] });
|
|
225
|
+
await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
|
|
226
|
+
await userEvent.click(await screen.findByRole('option', { name: 'Inscription' }));
|
|
227
|
+
|
|
228
|
+
expect(await screen.findByText(/no encounters for this provider match these filters/i)).toBeInTheDocument();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('drills down into the encounter that is clicked', async () => {
|
|
232
|
+
const { onSelectEncounter } = renderProviderEncounters();
|
|
233
|
+
await screen.findByRole('cell', { name: 'Oncology Consultation' });
|
|
234
|
+
|
|
235
|
+
await userEvent.click(screen.getAllByRole('button', { name: /2026/ })[0]);
|
|
236
|
+
|
|
237
|
+
expect(onSelectEncounter).toHaveBeenCalledWith('enc-1');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('goes back to the provider search', async () => {
|
|
241
|
+
const { onBackToSearch } = renderProviderEncounters();
|
|
242
|
+
|
|
243
|
+
await userEvent.click(screen.getByRole('button', { name: /back to provider search/i }));
|
|
244
|
+
|
|
245
|
+
expect(onBackToSearch).toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('says so when the provider is on no encounters', async () => {
|
|
249
|
+
mockRestApi({ results: [] });
|
|
250
|
+
renderProviderEncounters();
|
|
251
|
+
|
|
252
|
+
expect(await screen.findByText(/not recorded on any encounters/i)).toBeInTheDocument();
|
|
253
|
+
});
|
|
254
|
+
});
|