@pih/esm-audit-app 2.1.0

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 (84) hide show
  1. package/.turbo/turbo-lint.log +14 -0
  2. package/.turbo/turbo-test.log +12735 -0
  3. package/.turbo/turbo-typescript.log +1 -0
  4. package/README.md +116 -0
  5. package/dist/115.js +1 -0
  6. package/dist/115.js.map +1 -0
  7. package/dist/117.js +1 -0
  8. package/dist/117.js.map +1 -0
  9. package/dist/240.js +1 -0
  10. package/dist/240.js.map +1 -0
  11. package/dist/349.js +1 -0
  12. package/dist/349.js.map +1 -0
  13. package/dist/446.js +1 -0
  14. package/dist/446.js.map +1 -0
  15. package/dist/455.js +1 -0
  16. package/dist/455.js.map +1 -0
  17. package/dist/466.js +1 -0
  18. package/dist/466.js.map +1 -0
  19. package/dist/508.js +1 -0
  20. package/dist/508.js.map +1 -0
  21. package/dist/61.js +1 -0
  22. package/dist/61.js.map +1 -0
  23. package/dist/689.js +1 -0
  24. package/dist/689.js.map +1 -0
  25. package/dist/711.js +1 -0
  26. package/dist/711.js.map +1 -0
  27. package/dist/712.js +1 -0
  28. package/dist/712.js.map +1 -0
  29. package/dist/771.js +1 -0
  30. package/dist/771.js.map +1 -0
  31. package/dist/859.js +1 -0
  32. package/dist/859.js.map +1 -0
  33. package/dist/912.js +1 -0
  34. package/dist/912.js.map +1 -0
  35. package/dist/913.js +1 -0
  36. package/dist/913.js.map +1 -0
  37. package/dist/94.js +49 -0
  38. package/dist/94.js.map +1 -0
  39. package/dist/989.js +1 -0
  40. package/dist/989.js.map +1 -0
  41. package/dist/main.js +3 -0
  42. package/dist/main.js.map +1 -0
  43. package/dist/pih-esm-audit-app.js +3 -0
  44. package/dist/pih-esm-audit-app.js.buildmanifest.json +628 -0
  45. package/dist/pih-esm-audit-app.js.map +1 -0
  46. package/dist/routes.json +1 -0
  47. package/jest.config.js +3 -0
  48. package/package.json +53 -0
  49. package/rspack.config.js +1 -0
  50. package/src/audit/audit-format.ts +24 -0
  51. package/src/audit/audit.component.tsx +69 -0
  52. package/src/audit/audit.resource.test.tsx +174 -0
  53. package/src/audit/audit.resource.ts +409 -0
  54. package/src/audit/audit.scss +171 -0
  55. package/src/audit/encounter-audit.component.test.tsx +242 -0
  56. package/src/audit/encounter-audit.component.tsx +204 -0
  57. package/src/audit/encounter-filters.component.tsx +75 -0
  58. package/src/audit/encounter-filters.test.ts +119 -0
  59. package/src/audit/encounter-filters.ts +105 -0
  60. package/src/audit/obs-audit-table.component.tsx +181 -0
  61. package/src/audit/obs-audit.test.ts +170 -0
  62. package/src/audit/obs-audit.ts +156 -0
  63. package/src/audit/patient-activity.component.test.tsx +191 -0
  64. package/src/audit/patient-activity.component.tsx +203 -0
  65. package/src/audit/patient-activity.test.ts +155 -0
  66. package/src/audit/patient-activity.ts +156 -0
  67. package/src/audit/patient-encounters.component.tsx +134 -0
  68. package/src/audit/patient-record.component.test.tsx +193 -0
  69. package/src/audit/patient-record.component.tsx +115 -0
  70. package/src/audit/patient-search.component.test.tsx +73 -0
  71. package/src/audit/patient-search.component.tsx +120 -0
  72. package/src/config-schema.ts +36 -0
  73. package/src/dashboard-link.component.tsx +38 -0
  74. package/src/dashboard.meta.ts +11 -0
  75. package/src/declarations.d.tsx +3 -0
  76. package/src/index.ts +33 -0
  77. package/src/root.component.test.tsx +58 -0
  78. package/src/root.component.tsx +16 -0
  79. package/src/routes.json +20 -0
  80. package/src/types.ts +122 -0
  81. package/translations/en.json +89 -0
  82. package/translations/es.json +89 -0
  83. package/translations/fr.json +89 -0
  84. package/tsconfig.json +5 -0
@@ -0,0 +1,156 @@
1
+ import { type AuditEncounter, type AuditObs, type OpenmrsResourceRef } from '../types';
2
+
3
+ /** What someone did to a patient's record. */
4
+ export type AuditAction =
5
+ | 'encounterCreated'
6
+ | 'encounterChanged'
7
+ | 'encounterDeleted'
8
+ | 'obsRecorded'
9
+ | 'obsEdited'
10
+ | 'obsDeleted';
11
+
12
+ export interface AuditEvent {
13
+ /** Stable across renders: the row it came from plus what happened to it. */
14
+ key: string;
15
+ action: AuditAction;
16
+ /** When it happened, as the REST API reported it. */
17
+ timestamp: string;
18
+ user: OpenmrsResourceRef | undefined;
19
+ encounter: AuditEncounter;
20
+ /** The concept whose value was touched, for the observation actions. */
21
+ concept?: string;
22
+ }
23
+
24
+ /** One user's footprint on the part of the record that was read. */
25
+ export interface UserActivity {
26
+ userUuid: string;
27
+ userDisplay: string;
28
+ counts: Record<AuditAction, number>;
29
+ firstActivity: string;
30
+ lastActivity: string;
31
+ totalEvents: number;
32
+ }
33
+
34
+ /** The obs of one encounter, keyed by the encounter's uuid. */
35
+ export type ObsByEncounter = Record<string, Array<AuditObs>>;
36
+
37
+ /** Stands in for the user on events whose actor the API did not report. */
38
+ export const unknownUserUuid = 'unknown';
39
+
40
+ /**
41
+ * The key an event is grouped and filtered by. `summariseByUser` and the activity log both use it,
42
+ * so a row's counts and the events behind it can never disagree about who did what.
43
+ */
44
+ export function eventUserUuid(auditEvent: AuditEvent): string {
45
+ return auditEvent.user?.uuid ?? unknownUserUuid;
46
+ }
47
+
48
+ function event(
49
+ action: AuditAction,
50
+ timestamp: string | undefined,
51
+ user: OpenmrsResourceRef | undefined,
52
+ encounter: AuditEncounter,
53
+ keySuffix: string,
54
+ concept?: string,
55
+ ): AuditEvent | null {
56
+ return timestamp ? { key: `${keySuffix}:${action}`, action, timestamp, user, encounter, concept } : null;
57
+ }
58
+
59
+ /**
60
+ * Turns encounters and their observations into the list of things people did to the record.
61
+ *
62
+ * Every obs row was created by someone at some point, so each yields one creation event — an edit
63
+ * if it replaced an earlier obs, a plain recording otherwise. A voided obs yields a deletion event
64
+ * too, unless a later obs replaced it: that voiding is the other half of the successor's edit, and
65
+ * counting it again would make one correction look like two acts.
66
+ */
67
+ export function buildAuditEvents(encounters: Array<AuditEncounter>, obsByEncounter: ObsByEncounter): Array<AuditEvent> {
68
+ const events: Array<AuditEvent> = [];
69
+
70
+ for (const encounter of encounters) {
71
+ const audit = encounter.auditInfo;
72
+ events.push(
73
+ event('encounterCreated', audit?.dateCreated, audit?.creator, encounter, encounter.uuid),
74
+ event('encounterChanged', audit?.dateChanged, audit?.changedBy, encounter, encounter.uuid),
75
+ encounter.voided
76
+ ? event('encounterDeleted', audit?.dateVoided, audit?.voidedBy, encounter, encounter.uuid)
77
+ : null,
78
+ );
79
+
80
+ const obsList = obsByEncounter[encounter.uuid] ?? [];
81
+ const supersededUuids = new Set(
82
+ obsList.map((obs) => obs.previousVersion?.uuid).filter((uuid): uuid is string => Boolean(uuid)),
83
+ );
84
+
85
+ for (const obs of obsList) {
86
+ const obsAudit = obs.auditInfo;
87
+ const concept = obs.concept?.display;
88
+ events.push(
89
+ event(
90
+ obs.previousVersion ? 'obsEdited' : 'obsRecorded',
91
+ obsAudit?.dateCreated,
92
+ obsAudit?.creator,
93
+ encounter,
94
+ obs.uuid,
95
+ concept,
96
+ ),
97
+ obs.voided && !supersededUuids.has(obs.uuid)
98
+ ? event('obsDeleted', obsAudit?.dateVoided, obsAudit?.voidedBy, encounter, obs.uuid, concept)
99
+ : null,
100
+ );
101
+ }
102
+ }
103
+
104
+ return events
105
+ .filter((auditEvent): auditEvent is AuditEvent => auditEvent !== null)
106
+ .sort((a, b) => b.timestamp.localeCompare(a.timestamp));
107
+ }
108
+
109
+ function emptyCounts(): Record<AuditAction, number> {
110
+ return {
111
+ encounterCreated: 0,
112
+ encounterChanged: 0,
113
+ encounterDeleted: 0,
114
+ obsRecorded: 0,
115
+ obsEdited: 0,
116
+ obsDeleted: 0,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Who touched the record, and how much: one row per user, busiest first. Events whose user the
122
+ * API did not report are gathered under a single unknown user rather than dropped, so the counts
123
+ * still add up to what was read.
124
+ */
125
+ export function summariseByUser(events: Array<AuditEvent>): Array<UserActivity> {
126
+ const byUser = new Map<string, UserActivity>();
127
+
128
+ for (const auditEvent of events) {
129
+ const userUuid = eventUserUuid(auditEvent);
130
+ let activity = byUser.get(userUuid);
131
+ if (!activity) {
132
+ activity = {
133
+ userUuid,
134
+ userDisplay: auditEvent.user?.display ?? '',
135
+ counts: emptyCounts(),
136
+ firstActivity: auditEvent.timestamp,
137
+ lastActivity: auditEvent.timestamp,
138
+ totalEvents: 0,
139
+ };
140
+ byUser.set(userUuid, activity);
141
+ }
142
+
143
+ activity.counts[auditEvent.action] += 1;
144
+ activity.totalEvents += 1;
145
+ if (auditEvent.timestamp < activity.firstActivity) {
146
+ activity.firstActivity = auditEvent.timestamp;
147
+ }
148
+ if (auditEvent.timestamp > activity.lastActivity) {
149
+ activity.lastActivity = auditEvent.timestamp;
150
+ }
151
+ }
152
+
153
+ return Array.from(byUser.values()).sort(
154
+ (a, b) => b.totalEvents - a.totalEvents || a.userDisplay.localeCompare(b.userDisplay),
155
+ );
156
+ }
@@ -0,0 +1,134 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import {
4
+ DataTableSkeleton,
5
+ Pagination,
6
+ Table,
7
+ TableBody,
8
+ TableCell,
9
+ TableContainer,
10
+ TableHead,
11
+ TableHeader,
12
+ TableRow,
13
+ Tag,
14
+ } from '@carbon/react';
15
+ import { ErrorState, useConfig } from '@openmrs/esm-framework';
16
+ import { type Config } from '../config-schema';
17
+ import { type AuditEncounter, type AuditPatient } from '../types';
18
+ import { formatAuditDatetime } from './audit-format';
19
+ import { usePatientEncounters } from './audit.resource';
20
+ import { type EncounterFilters, hasActiveFilters } from './encounter-filters';
21
+ import styles from './audit.scss';
22
+
23
+ interface PatientEncountersProps {
24
+ patient: AuditPatient | undefined;
25
+ includeDeleted: boolean;
26
+ filters: EncounterFilters;
27
+ isLoadingPatient: boolean;
28
+ onSelectEncounter(encounterUuid: string): void;
29
+ }
30
+
31
+ function providerNames(encounter: AuditEncounter): string {
32
+ return (encounter.encounterProviders ?? [])
33
+ .filter((encounterProvider) => !encounterProvider.voided)
34
+ .map((encounterProvider) => encounterProvider.provider?.display)
35
+ .filter(Boolean)
36
+ .join(', ');
37
+ }
38
+
39
+ /**
40
+ * Every encounter recorded for the patient, most recent first, as the way into one encounter's
41
+ * audit trail. Deleted encounters are hidden by default, as they were on the legacy admin page.
42
+ */
43
+ export default function PatientEncounters({
44
+ patient,
45
+ includeDeleted,
46
+ filters,
47
+ isLoadingPatient,
48
+ onSelectEncounter,
49
+ }: PatientEncountersProps) {
50
+ const { t } = useTranslation();
51
+ const config = useConfig<Config>();
52
+ const [pageSize, setPageSize] = useState(config.encountersPageSize ?? 10);
53
+ const pageSizes = useMemo(() => Array.from(new Set([pageSize, 10, 20, 50])).sort((a, b) => a - b), [pageSize]);
54
+
55
+ const { encounters, totalCount, currentPage, goTo, error, isLoading } = usePatientEncounters(
56
+ patient,
57
+ includeDeleted,
58
+ filters,
59
+ pageSize,
60
+ );
61
+
62
+ if (error) {
63
+ return <ErrorState error={error} headerTitle={t('encounters', 'Encounters')} />;
64
+ }
65
+
66
+ if (isLoadingPatient || isLoading) {
67
+ return <DataTableSkeleton columnCount={6} compact role="progressbar" showHeader={false} showToolbar={false} />;
68
+ }
69
+
70
+ if (encounters.length === 0) {
71
+ return (
72
+ <p className={styles.emptyState}>
73
+ {hasActiveFilters(filters)
74
+ ? t('noEncountersMatchFilters', 'No encounters match these filters.')
75
+ : t('noEncountersFound', 'This patient has no encounters to audit.')}
76
+ </p>
77
+ );
78
+ }
79
+
80
+ return (
81
+ <>
82
+ <TableContainer className={styles.tableContainer}>
83
+ <Table size="sm" useZebraStyles>
84
+ <TableHead>
85
+ <TableRow>
86
+ <TableHeader>{t('encounterDate', 'Encounter date')}</TableHeader>
87
+ <TableHeader>{t('encounterType', 'Encounter type')}</TableHeader>
88
+ <TableHeader>{t('form', 'Form')}</TableHeader>
89
+ <TableHeader>{t('provider', 'Provider')}</TableHeader>
90
+ <TableHeader>{t('location', 'Location')}</TableHeader>
91
+ <TableHeader>{t('status', 'Status')}</TableHeader>
92
+ </TableRow>
93
+ </TableHead>
94
+ <TableBody>
95
+ {encounters.map((encounter) => (
96
+ <TableRow
97
+ className={styles.clickableRow}
98
+ key={encounter.uuid}
99
+ onClick={() => onSelectEncounter(encounter.uuid)}>
100
+ <TableCell>
101
+ <button
102
+ className={styles.linkButton}
103
+ onClick={(event) => {
104
+ event.stopPropagation();
105
+ onSelectEncounter(encounter.uuid);
106
+ }}
107
+ type="button">
108
+ {formatAuditDatetime(encounter.encounterDatetime)}
109
+ </button>
110
+ </TableCell>
111
+ <TableCell>{encounter.encounterType?.display}</TableCell>
112
+ <TableCell>{encounter.form?.display}</TableCell>
113
+ <TableCell>{providerNames(encounter)}</TableCell>
114
+ <TableCell>{encounter.location?.display}</TableCell>
115
+ <TableCell>{encounter.voided ? <Tag type="red">{t('deleted', 'Deleted')}</Tag> : null}</TableCell>
116
+ </TableRow>
117
+ ))}
118
+ </TableBody>
119
+ </Table>
120
+ </TableContainer>
121
+ <Pagination
122
+ onChange={({ page: nextPage, pageSize: nextPageSize }) => {
123
+ setPageSize(nextPageSize);
124
+ goTo(nextPage);
125
+ }}
126
+ page={currentPage}
127
+ pageSize={pageSize}
128
+ pageSizes={pageSizes}
129
+ size="sm"
130
+ totalItems={totalCount}
131
+ />
132
+ </>
133
+ );
134
+ }
@@ -0,0 +1,193 @@
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, type AuditPatient } from '../types';
7
+ import PatientRecord from './patient-record.component';
8
+
9
+ const mockOpenmrsFetch = jest.mocked(openmrsFetch);
10
+ const mockUseConfig = jest.mocked(useConfig);
11
+
12
+ const mockPatient: AuditPatient = {
13
+ uuid: 'patient-1',
14
+ display: 'Y2AHXV - Dave TestPatient',
15
+ identifiers: [{ uuid: 'id-1', identifier: 'Y2AHXV', preferred: true }],
16
+ person: { display: 'Dave TestPatient', gender: 'M', age: 42, birthdate: '1984-03-14T00:00:00.000+0000' },
17
+ };
18
+
19
+ const consultation: AuditEncounter = {
20
+ uuid: 'enc-1',
21
+ encounterDatetime: '2026-04-18T09:00:00.000+0000',
22
+ voided: false,
23
+ encounterType: { uuid: 'type-1', display: 'Oncology Consultation' },
24
+ form: { uuid: 'form-1', display: 'Oncology Consult Note' },
25
+ location: { uuid: 'loc-1', display: 'Klinik Ekstèn' },
26
+ encounterProviders: [{ uuid: 'ep-1', provider: { uuid: 'prov-1', display: 'Louidor Jean paul' } }],
27
+ };
28
+
29
+ const deletedCheckin: AuditEncounter = {
30
+ uuid: 'enc-2',
31
+ encounterDatetime: '2026-04-11T08:00:00.000+0000',
32
+ voided: true,
33
+ encounterType: { uuid: 'type-2', display: 'Inscription' },
34
+ form: { uuid: 'form-2', display: 'LiveCheckin' },
35
+ location: { uuid: 'loc-2', display: 'CDI Klinik Ekstèn Jeneral' },
36
+ patient: { uuid: 'patient-1', display: 'Y2AHXV - Dave TestPatient' },
37
+ };
38
+
39
+ /** The free-text encounter search matches on identifier, so it can return other patients' rows. */
40
+ const otherPatientsEncounter: AuditEncounter = {
41
+ uuid: 'enc-3',
42
+ encounterDatetime: '2026-05-01T08:00:00.000+0000',
43
+ voided: false,
44
+ encounterType: { uuid: 'type-3', display: 'Consultation' },
45
+ patient: { uuid: 'patient-2', display: 'Y2AHXV2 - Someone Else' },
46
+ };
47
+
48
+ /**
49
+ * The encounter list is the only read that asks for a total count; the unfiltered scan behind the
50
+ * encounter-type dropdown and the activity view reads the same endpoint without one.
51
+ */
52
+ function mockRestApi({ pagedEncounters = [consultation] }: { pagedEncounters?: Array<AuditEncounter> } = {}) {
53
+ mockOpenmrsFetch.mockImplementation((url: string) => {
54
+ if (url.includes('/patient/patient-1')) {
55
+ return Promise.resolve({ data: mockPatient }) as ReturnType<typeof openmrsFetch>;
56
+ }
57
+ if (url.includes('/obs?encounter=')) {
58
+ return Promise.resolve({ data: { results: [] } }) as ReturnType<typeof openmrsFetch>;
59
+ }
60
+ if (url.includes('/encounter?q=')) {
61
+ return Promise.resolve({
62
+ data: {
63
+ results: [{ ...consultation, patient: deletedCheckin.patient }, deletedCheckin, otherPatientsEncounter],
64
+ },
65
+ }) as ReturnType<typeof openmrsFetch>;
66
+ }
67
+ if (url.includes('totalCount=true')) {
68
+ return Promise.resolve({
69
+ data: { results: pagedEncounters, totalCount: pagedEncounters.length },
70
+ }) as ReturnType<typeof openmrsFetch>;
71
+ }
72
+ return Promise.resolve({ data: { results: [consultation, deletedCheckin] } }) as ReturnType<typeof openmrsFetch>;
73
+ });
74
+ }
75
+
76
+ function renderPatientRecord() {
77
+ const onSelectEncounter = jest.fn();
78
+ const onBackToSearch = jest.fn();
79
+ const onSelectView = jest.fn();
80
+ render(
81
+ <SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
82
+ <PatientRecord
83
+ onBackToSearch={onBackToSearch}
84
+ onSelectEncounter={onSelectEncounter}
85
+ onSelectView={onSelectView}
86
+ patientUuid="patient-1"
87
+ view="encounters"
88
+ />
89
+ </SWRConfig>,
90
+ );
91
+ return { onSelectEncounter, onBackToSearch, onSelectView };
92
+ }
93
+
94
+ describe('<PatientRecord />', () => {
95
+ beforeEach(() => {
96
+ mockUseConfig.mockReturnValue({
97
+ patientChartUrl: '${openmrsSpaBase}/patient/${patientUuid}/chart',
98
+ });
99
+ mockRestApi();
100
+ });
101
+
102
+ it("lists the patient's encounters, without the deleted ones", async () => {
103
+ renderPatientRecord();
104
+
105
+ expect(await screen.findByText('Dave TestPatient')).toBeInTheDocument();
106
+ expect(screen.getByRole('cell', { name: 'Oncology Consultation' })).toBeInTheDocument();
107
+ expect(screen.getByRole('cell', { name: 'Louidor Jean paul' })).toBeInTheDocument();
108
+ expect(screen.queryByText('LiveCheckin')).not.toBeInTheDocument();
109
+ expect(mockOpenmrsFetch).toHaveBeenCalledWith(expect.stringContaining('/ws/rest/v1/encounter?patient=patient-1'));
110
+ });
111
+
112
+ it('includes deleted encounters on request, and only this patient’s', async () => {
113
+ renderPatientRecord();
114
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
115
+
116
+ await userEvent.click(screen.getByRole('checkbox', { name: /include deleted encounters/i }));
117
+
118
+ expect(await screen.findByRole('cell', { name: 'LiveCheckin' })).toBeInTheDocument();
119
+ expect(screen.queryByRole('cell', { name: 'Consultation' })).not.toBeInTheDocument();
120
+ expect(mockOpenmrsFetch).toHaveBeenCalledWith(
121
+ expect.stringContaining('/ws/rest/v1/encounter?q=Y2AHXV&includeAll=true'),
122
+ );
123
+
124
+ const deletedRow = screen.getAllByRole('row').find((row) => row.textContent?.includes('LiveCheckin'));
125
+ expect(deletedRow).toHaveTextContent('Deleted');
126
+ });
127
+
128
+ it('drills down into the encounter that is clicked', async () => {
129
+ const { onSelectEncounter } = renderPatientRecord();
130
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
131
+
132
+ await userEvent.click(screen.getAllByRole('button', { name: /2026/ })[0]);
133
+
134
+ expect(onSelectEncounter).toHaveBeenCalledWith('enc-1');
135
+ });
136
+
137
+ it("offers only the encounter types this patient's encounters use", async () => {
138
+ renderPatientRecord();
139
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
140
+
141
+ await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
142
+
143
+ const options = within(screen.getByRole('listbox')).getAllByRole('option');
144
+ expect(options.map((option) => option.textContent)).toEqual(['Inscription', 'Oncology Consultation']);
145
+ });
146
+
147
+ it('asks the server for one encounter type when one is chosen', async () => {
148
+ renderPatientRecord();
149
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
150
+
151
+ await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
152
+ await userEvent.click(await screen.findByRole('option', { name: 'Inscription' }));
153
+
154
+ await waitFor(() =>
155
+ expect(mockOpenmrsFetch).toHaveBeenCalledWith(expect.stringContaining('&encounterType=type-2')),
156
+ );
157
+ });
158
+
159
+ it('offers to clear the filters once one is set, and says when nothing matches', async () => {
160
+ renderPatientRecord();
161
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
162
+ expect(screen.queryByRole('button', { name: /clear filters/i })).not.toBeInTheDocument();
163
+
164
+ mockRestApi({ pagedEncounters: [] });
165
+
166
+ await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
167
+ await userEvent.click(await screen.findByRole('option', { name: 'Inscription' }));
168
+
169
+ expect(await screen.findByText(/no encounters match these filters/i)).toBeInTheDocument();
170
+ expect(screen.getByRole('button', { name: /clear filters/i })).toBeInTheDocument();
171
+
172
+ await userEvent.click(screen.getByRole('button', { name: /clear filters/i }));
173
+
174
+ expect(screen.queryByRole('button', { name: /clear filters/i })).not.toBeInTheDocument();
175
+ });
176
+
177
+ it('goes back to the patient search', async () => {
178
+ const { onBackToSearch } = renderPatientRecord();
179
+
180
+ await userEvent.click(screen.getByRole('button', { name: /back to patient search/i }));
181
+
182
+ expect(onBackToSearch).toHaveBeenCalled();
183
+ });
184
+
185
+ it('switches to the record activity view', async () => {
186
+ const { onSelectView } = renderPatientRecord();
187
+ await screen.findByRole('cell', { name: 'Oncology Consultation' });
188
+
189
+ await userEvent.click(screen.getByRole('tab', { name: /patient activity/i }));
190
+
191
+ expect(onSelectView).toHaveBeenCalledWith('activity');
192
+ });
193
+ });
@@ -0,0 +1,115 @@
1
+ import React, { useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Button, Checkbox, InlineNotification, Tab, TabList, TabPanel, TabPanels, Tabs } from '@carbon/react';
4
+ import { ArrowLeftIcon, ErrorState } from '@openmrs/esm-framework';
5
+ import { canListDeletedEncounters, getPreferredIdentifier, useAuditPatient } from './audit.resource';
6
+ import { type EncounterFilters } from './encounter-filters';
7
+ import EncounterFiltersBar from './encounter-filters.component';
8
+ import PatientActivity from './patient-activity.component';
9
+ import PatientEncounters from './patient-encounters.component';
10
+ import styles from './audit.scss';
11
+
12
+ export type PatientRecordView = 'encounters' | 'activity';
13
+
14
+ export const patientRecordViews: Array<PatientRecordView> = ['encounters', 'activity'];
15
+
16
+ interface PatientRecordProps {
17
+ patientUuid: string;
18
+ view: PatientRecordView;
19
+ onSelectView(view: PatientRecordView): void;
20
+ onSelectEncounter(encounterUuid: string): void;
21
+ onBackToSearch(): void;
22
+ }
23
+
24
+ /**
25
+ * Step two of the audit trail. The patient's record can be read two ways — as the list of
26
+ * encounters to drill into, or as the record of who has touched it — and both are narrowed by the
27
+ * same filters, which is why they share this component's state rather than holding their own.
28
+ */
29
+ export default function PatientRecord({
30
+ patientUuid,
31
+ view,
32
+ onSelectView,
33
+ onSelectEncounter,
34
+ onBackToSearch,
35
+ }: PatientRecordProps) {
36
+ const { t } = useTranslation();
37
+ const [includeDeleted, setIncludeDeleted] = useState(false);
38
+ const [filters, setFilters] = useState<EncounterFilters>({});
39
+
40
+ const { patient, error: patientError, isLoading: isLoadingPatient } = useAuditPatient(patientUuid);
41
+ const cannotIncludeDeleted = includeDeleted && Boolean(patient) && !canListDeletedEncounters(patient);
42
+
43
+ return (
44
+ <div className={styles.section}>
45
+ <Button className={styles.backButton} kind="ghost" onClick={onBackToSearch} renderIcon={ArrowLeftIcon} size="sm">
46
+ {t('backToPatientSearch', 'Back to patient search')}
47
+ </Button>
48
+ <div className={styles.contextHeader}>
49
+ <span className={styles.contextTitle}>{patient?.person?.display ?? patient?.display ?? ''}</span>
50
+ <span className={styles.contextSubtitle}>
51
+ {[getPreferredIdentifier(patient), patient?.person?.gender, patient?.person?.age]
52
+ .filter((part) => part !== undefined && part !== '')
53
+ .join(' · ')}
54
+ </span>
55
+ </div>
56
+
57
+ <EncounterFiltersBar filters={filters} includeDeleted={includeDeleted} onChange={setFilters} patient={patient} />
58
+
59
+ <div className={styles.toolbar}>
60
+ <Checkbox
61
+ checked={includeDeleted}
62
+ id="include-deleted-encounters"
63
+ labelText={t('includeDeletedEncounters', 'Include deleted encounters')}
64
+ onChange={(_event, { checked }) => setIncludeDeleted(checked)}
65
+ />
66
+ </div>
67
+
68
+ {cannotIncludeDeleted ? (
69
+ <InlineNotification
70
+ className={styles.inlineNotification}
71
+ hideCloseButton
72
+ kind="warning"
73
+ lowContrast
74
+ subtitle={t(
75
+ 'cannotIncludeDeletedEncounters',
76
+ 'Deleted encounters can only be listed for a patient who has an identifier.',
77
+ )}
78
+ title={t('deletedEncountersUnavailable', 'Deleted encounters unavailable')}
79
+ />
80
+ ) : null}
81
+
82
+ {patientError ? (
83
+ <ErrorState error={patientError} headerTitle={t('encounters', 'Encounters')} />
84
+ ) : (
85
+ <Tabs
86
+ onChange={({ selectedIndex }) => onSelectView(patientRecordViews[selectedIndex])}
87
+ selectedIndex={Math.max(0, patientRecordViews.indexOf(view))}>
88
+ <TabList aria-label={t('auditViews', 'Audit views')}>
89
+ <Tab>{t('encounters', 'Encounters')}</Tab>
90
+ <Tab>{t('recordActivity', 'Patient activity')}</Tab>
91
+ </TabList>
92
+ <TabPanels>
93
+ <TabPanel>
94
+ <PatientEncounters
95
+ filters={filters}
96
+ includeDeleted={includeDeleted}
97
+ isLoadingPatient={isLoadingPatient}
98
+ onSelectEncounter={onSelectEncounter}
99
+ patient={patient}
100
+ />
101
+ </TabPanel>
102
+ <TabPanel>
103
+ <PatientActivity
104
+ filters={filters}
105
+ includeDeleted={includeDeleted}
106
+ onSelectEncounter={onSelectEncounter}
107
+ patient={patient}
108
+ />
109
+ </TabPanel>
110
+ </TabPanels>
111
+ </Tabs>
112
+ )}
113
+ </div>
114
+ );
115
+ }
@@ -0,0 +1,73 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { SWRConfig } from 'swr';
5
+ import { openmrsFetch } from '@openmrs/esm-framework';
6
+ import PatientSearch from './patient-search.component';
7
+
8
+ const mockOpenmrsFetch = jest.mocked(openmrsFetch);
9
+
10
+ const mockPatients = [
11
+ {
12
+ uuid: 'patient-1',
13
+ display: 'Y2AHXV - Dave TestPatient',
14
+ identifiers: [{ uuid: 'id-1', identifier: 'Y2AHXV', preferred: true }],
15
+ person: { display: 'Dave TestPatient', gender: 'M', age: 42, birthdate: '1984-03-14T00:00:00.000+0000' },
16
+ },
17
+ ];
18
+
19
+ function renderPatientSearch() {
20
+ const onSelectPatient = jest.fn();
21
+ render(
22
+ <SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
23
+ <PatientSearch onSelectPatient={onSelectPatient} />
24
+ </SWRConfig>,
25
+ );
26
+ return onSelectPatient;
27
+ }
28
+
29
+ describe('<PatientSearch />', () => {
30
+ beforeEach(() => {
31
+ mockOpenmrsFetch.mockResolvedValue({
32
+ data: { results: mockPatients, totalCount: 1 },
33
+ } as unknown as ReturnType<typeof openmrsFetch>);
34
+ });
35
+
36
+ it('asks for a search term before searching', () => {
37
+ renderPatientSearch();
38
+
39
+ expect(screen.getByText(/enter a patient name or identifier/i)).toBeInTheDocument();
40
+ expect(mockOpenmrsFetch).not.toHaveBeenCalled();
41
+ });
42
+
43
+ it('searches patients by name or identifier and lists what it finds', async () => {
44
+ renderPatientSearch();
45
+
46
+ await userEvent.type(screen.getByRole('searchbox'), 'Y2AHXV');
47
+
48
+ expect(await screen.findByRole('button', { name: 'Dave TestPatient' })).toBeInTheDocument();
49
+ expect(screen.getByRole('cell', { name: 'Y2AHXV' })).toBeInTheDocument();
50
+ expect(screen.getByRole('cell', { name: '42' })).toBeInTheDocument();
51
+ expect(mockOpenmrsFetch).toHaveBeenCalledWith(expect.stringContaining('/ws/rest/v1/patient?q=Y2AHXV'));
52
+ });
53
+
54
+ it('drills down into the patient that is clicked', async () => {
55
+ const onSelectPatient = renderPatientSearch();
56
+
57
+ await userEvent.type(screen.getByRole('searchbox'), 'Y2AHXV');
58
+ await userEvent.click(await screen.findByRole('button', { name: 'Dave TestPatient' }));
59
+
60
+ expect(onSelectPatient).toHaveBeenCalledWith('patient-1');
61
+ });
62
+
63
+ it('says so when nothing matches', async () => {
64
+ mockOpenmrsFetch.mockResolvedValue({ data: { results: [], totalCount: 0 } } as unknown as ReturnType<
65
+ typeof openmrsFetch
66
+ >);
67
+ renderPatientSearch();
68
+
69
+ await userEvent.type(screen.getByRole('searchbox'), 'nobody');
70
+
71
+ expect(await screen.findByText(/no patients match "nobody"/i)).toBeInTheDocument();
72
+ });
73
+ });