@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,431 @@
|
|
|
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 UserEncounters from './user-encounters.component';
|
|
7
|
+
import { stubDateRangePicker } from './date-range-picker.test-stub';
|
|
8
|
+
|
|
9
|
+
const mockOpenmrsFetch = jest.mocked(openmrsFetch);
|
|
10
|
+
const mockUseConfig = jest.mocked(useConfig);
|
|
11
|
+
|
|
12
|
+
const user = {
|
|
13
|
+
uuid: 'user-1',
|
|
14
|
+
display: 'butch',
|
|
15
|
+
username: 'butch',
|
|
16
|
+
person: { uuid: 'person-1', display: 'Cos John' },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Strictly descending timestamps, so observation 0 is the most recent change — which is the order
|
|
21
|
+
* the audit endpoint returns and the order the merge relies on.
|
|
22
|
+
*/
|
|
23
|
+
const scanEpoch = Date.UTC(2026, 8, 1, 12, 0, 0);
|
|
24
|
+
|
|
25
|
+
function dayFromIndex(index: number): string {
|
|
26
|
+
return new Date(scanEpoch - index * 60000).toISOString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Alternating types, so filtering by one keeps every other encounter. */
|
|
30
|
+
const consultationType = { uuid: 'type-consultation', display: 'Consultation' };
|
|
31
|
+
const vitalsType = { uuid: 'type-vitals', display: 'Vitals' };
|
|
32
|
+
|
|
33
|
+
/** A type nothing in the trail uses, since the dropdown offers every type in use in the system. */
|
|
34
|
+
const unusedType = { uuid: 'type-unused', display: 'Zibra Admission' };
|
|
35
|
+
|
|
36
|
+
/** A type nothing in the system has been recorded against, which the dropdown leaves out. */
|
|
37
|
+
const neverRecordedType = { uuid: 'type-never', display: 'Never Recorded' };
|
|
38
|
+
|
|
39
|
+
function encounter(index: number) {
|
|
40
|
+
return {
|
|
41
|
+
uuid: `enc-${index}`,
|
|
42
|
+
encounterDatetime: '2026-04-18T09:00:00.000+0000',
|
|
43
|
+
encounterType: index % 2 === 0 ? consultationType : vitalsType,
|
|
44
|
+
location: { uuid: 'loc-1', display: 'Klinik Ekstèn' },
|
|
45
|
+
patient: { uuid: `patient-${index}`, display: `Patient ${index}` },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Observations newest first, `obsPerEncounter` of them on each encounter — the shape a form-entered
|
|
51
|
+
* record has, and the one that makes discovering ten encounters cost more than ten observations.
|
|
52
|
+
*/
|
|
53
|
+
function createdObsPage(startIndex: number, count: number, obsPerEncounter: number) {
|
|
54
|
+
return Array.from({ length: count }, (_, offset) => {
|
|
55
|
+
const index = startIndex + offset;
|
|
56
|
+
return {
|
|
57
|
+
uuid: `obs-${index}`,
|
|
58
|
+
encounter: encounter(Math.floor(index / obsPerEncounter)),
|
|
59
|
+
auditInfo: { dateCreated: dayFromIndex(index) },
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function startIndexOf(url: string): number {
|
|
65
|
+
return Number(/startIndex=(\d+)/.exec(url)?.[1] ?? 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function limitOf(url: string): number {
|
|
69
|
+
return Number(/limit=(\d+)/.exec(url)?.[1] ?? 100);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Serves `totalCreated` observations, honouring the startIndex and limit asked for — a page shorter
|
|
74
|
+
* than the limit is what tells the client the search is exhausted, so the mock must not invent one.
|
|
75
|
+
*/
|
|
76
|
+
function mockRestApi({ totalCreated = 250, obsPerEncounter = 20 } = {}) {
|
|
77
|
+
mockOpenmrsFetch.mockImplementation((url: string) => {
|
|
78
|
+
if (url.includes('/pihapps/obs?') && url.includes('createdBy=')) {
|
|
79
|
+
const start = startIndexOf(url);
|
|
80
|
+
const count = Math.max(0, Math.min(limitOf(url), totalCreated - start));
|
|
81
|
+
return Promise.resolve({
|
|
82
|
+
data: { results: createdObsPage(start, count, obsPerEncounter) },
|
|
83
|
+
}) as ReturnType<typeof openmrsFetch>;
|
|
84
|
+
}
|
|
85
|
+
if (url.includes('/pihapps/obs?') && url.includes('voidedBy=')) {
|
|
86
|
+
return Promise.resolve({ data: { results: [] } }) as ReturnType<typeof openmrsFetch>;
|
|
87
|
+
}
|
|
88
|
+
if (url.includes('/pihapps/encounterTypeCount')) {
|
|
89
|
+
// deliberately unsorted, and including a type the trail does not use and one nothing uses
|
|
90
|
+
const counts = [
|
|
91
|
+
{ encounterType: vitalsType, count: 40 },
|
|
92
|
+
{ encounterType: neverRecordedType, count: 0 },
|
|
93
|
+
{ encounterType: unusedType, count: 3 },
|
|
94
|
+
{ encounterType: consultationType, count: 40 },
|
|
95
|
+
];
|
|
96
|
+
// the endpoint leaves out unused types only when asked, so the mock does too
|
|
97
|
+
const results = url.includes('onlyUsed=true') ? counts.filter((entry) => entry.count > 0) : counts;
|
|
98
|
+
return Promise.resolve({ data: { results } }) as ReturnType<typeof openmrsFetch>;
|
|
99
|
+
}
|
|
100
|
+
if (url.includes('/obs?encounter=')) {
|
|
101
|
+
return Promise.resolve({
|
|
102
|
+
data: { results: [{ uuid: 'o1', auditInfo: { creator: { uuid: 'user-1' } } }] },
|
|
103
|
+
}) as ReturnType<typeof openmrsFetch>;
|
|
104
|
+
}
|
|
105
|
+
return Promise.resolve({ data: user }) as ReturnType<typeof openmrsFetch>;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderUserEncounters() {
|
|
110
|
+
const onSelectEncounter = jest.fn();
|
|
111
|
+
const onBackToSearch = jest.fn();
|
|
112
|
+
render(
|
|
113
|
+
<SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
|
|
114
|
+
<UserEncounters onBackToSearch={onBackToSearch} onSelectEncounter={onSelectEncounter} userUuid="user-1" />
|
|
115
|
+
</SWRConfig>,
|
|
116
|
+
);
|
|
117
|
+
return { onSelectEncounter, onBackToSearch };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function auditRequests() {
|
|
121
|
+
return mockOpenmrsFetch.mock.calls.map(([url]) => url as string).filter((url) => url.includes('/pihapps/obs?'));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function dataRows() {
|
|
125
|
+
return screen.getAllByRole('row').filter((row) => row.querySelectorAll('td').length > 0);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe('<UserEncounters />', () => {
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
stubDateRangePicker();
|
|
131
|
+
mockUseConfig.mockReturnValue({ encountersPageSize: 10 });
|
|
132
|
+
mockRestApi();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('asks the pihapps audit endpoint what the user created and what they voided', async () => {
|
|
136
|
+
renderUserEncounters();
|
|
137
|
+
|
|
138
|
+
await screen.findByText('Encounters 1–10');
|
|
139
|
+
expect(auditRequests().some((url) => url.includes('createdBy=user-1'))).toBe(true);
|
|
140
|
+
expect(auditRequests().some((url) => url.includes('voidedBy=user-1'))).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The endpoint leaves voided observations out unless asked, so an audit has to ask: without this
|
|
145
|
+
* the voidedBy stream would come back empty and nothing deleted would ever be listed.
|
|
146
|
+
*/
|
|
147
|
+
it('asks for voided observations to be included', async () => {
|
|
148
|
+
renderUserEncounters();
|
|
149
|
+
|
|
150
|
+
await screen.findByText('Encounters 1–10');
|
|
151
|
+
expect(auditRequests().every((url) => url.includes('includeVoided=true'))).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The endpoint sorts nothing unless asked, and each stream wants the column belonging to the
|
|
156
|
+
* action it searched on — ordering both by the same one would bury what the other is about.
|
|
157
|
+
*/
|
|
158
|
+
it('orders each stream by the audit action it searched on', async () => {
|
|
159
|
+
renderUserEncounters();
|
|
160
|
+
|
|
161
|
+
await screen.findByText('Encounters 1–10');
|
|
162
|
+
const created = auditRequests().filter((url) => url.includes('createdBy='));
|
|
163
|
+
const voided = auditRequests().filter((url) => url.includes('voidedBy='));
|
|
164
|
+
|
|
165
|
+
expect(created.length).toBeGreaterThan(0);
|
|
166
|
+
expect(voided.length).toBeGreaterThan(0);
|
|
167
|
+
expect(created.every((url) => url.includes('sortBy=dateCreated-desc&sortBy=obsId-desc'))).toBe(true);
|
|
168
|
+
expect(voided.every((url) => url.includes('sortBy=dateVoided-desc&sortBy=obsId-desc'))).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('shows one page of encounters, most recently changed first', async () => {
|
|
172
|
+
renderUserEncounters();
|
|
173
|
+
|
|
174
|
+
await screen.findByText('Encounters 1–10');
|
|
175
|
+
const rows = dataRows();
|
|
176
|
+
expect(rows).toHaveLength(10);
|
|
177
|
+
expect(rows[0]).toHaveTextContent('Patient 0');
|
|
178
|
+
expect(rows[9]).toHaveTextContent('Patient 9');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/** The point of the exercise: a first page must not read the account's whole trail. */
|
|
182
|
+
it('reads only as far into the trail as the first page needs', async () => {
|
|
183
|
+
// 5000 observations in all, but 20 per encounter means ~220 place the first eleven encounters
|
|
184
|
+
mockRestApi({ totalCreated: 5000, obsPerEncounter: 20 });
|
|
185
|
+
renderUserEncounters();
|
|
186
|
+
|
|
187
|
+
await screen.findByText('Encounters 1–10');
|
|
188
|
+
const created = auditRequests().filter((url) => url.includes('createdBy=user-1'));
|
|
189
|
+
expect(created.length).toBeLessThanOrEqual(4);
|
|
190
|
+
expect(startIndexOf(created[created.length - 1])).toBeLessThan(400);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('reads further only when the next page is asked for', async () => {
|
|
194
|
+
mockRestApi({ totalCreated: 5000, obsPerEncounter: 20 });
|
|
195
|
+
renderUserEncounters();
|
|
196
|
+
await screen.findByText('Encounters 1–10');
|
|
197
|
+
const beforePaging = auditRequests().length;
|
|
198
|
+
|
|
199
|
+
await userEvent.click(screen.getByRole('button', { name: 'Next page' }));
|
|
200
|
+
|
|
201
|
+
expect(await screen.findByText('Encounters 11–20')).toBeInTheDocument();
|
|
202
|
+
expect(auditRequests().length).toBeGreaterThan(beforePaging);
|
|
203
|
+
expect(dataRows()[0]).toHaveTextContent('Patient 10');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('goes back to a page already read without reading again', async () => {
|
|
207
|
+
mockRestApi({ totalCreated: 5000, obsPerEncounter: 20 });
|
|
208
|
+
renderUserEncounters();
|
|
209
|
+
await screen.findByText('Encounters 1–10');
|
|
210
|
+
await userEvent.click(screen.getByRole('button', { name: 'Next page' }));
|
|
211
|
+
await screen.findByText('Encounters 11–20');
|
|
212
|
+
const afterPaging = auditRequests().length;
|
|
213
|
+
|
|
214
|
+
await userEvent.click(screen.getByRole('button', { name: 'Previous page' }));
|
|
215
|
+
|
|
216
|
+
expect(await screen.findByText('Encounters 1–10')).toBeInTheDocument();
|
|
217
|
+
expect(auditRequests()).toHaveLength(afterPaging);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('stops offering a next page at the end of the trail', async () => {
|
|
221
|
+
// twelve encounters in all, so the second page is short and there is no third
|
|
222
|
+
mockRestApi({ totalCreated: 12, obsPerEncounter: 1 });
|
|
223
|
+
renderUserEncounters();
|
|
224
|
+
|
|
225
|
+
await screen.findByText('Encounters 1–10');
|
|
226
|
+
await userEvent.click(screen.getByRole('button', { name: 'Next page' }));
|
|
227
|
+
|
|
228
|
+
expect(await screen.findByText('Encounters 11–12')).toBeInTheDocument();
|
|
229
|
+
expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('counts each row’s observations exactly, from the encounter itself', async () => {
|
|
233
|
+
renderUserEncounters();
|
|
234
|
+
|
|
235
|
+
await screen.findByText('Encounters 1–10');
|
|
236
|
+
const cells = dataRows()[0].querySelectorAll('td');
|
|
237
|
+
expect(cells[5]).toHaveTextContent('1');
|
|
238
|
+
expect(cells[6]).toHaveTextContent('0');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The range picker is stubbed as a text input read as MM/DD/YYYY–MM/DD/YYYY, which like the real
|
|
243
|
+
* one reports nothing until both ends of the range are complete.
|
|
244
|
+
*/
|
|
245
|
+
const septemberRange = '09/01/2026–09/30/2026';
|
|
246
|
+
it('narrows the search on the server when a date range is picked', async () => {
|
|
247
|
+
renderUserEncounters();
|
|
248
|
+
await screen.findByText('Encounters 1–10');
|
|
249
|
+
|
|
250
|
+
await userEvent.clear(screen.getByLabelText('Changed between'));
|
|
251
|
+
await userEvent.type(screen.getByLabelText('Changed between'), septemberRange);
|
|
252
|
+
|
|
253
|
+
await waitFor(() =>
|
|
254
|
+
expect(
|
|
255
|
+
auditRequests().some((url) => url.includes('createdOnOrAfter=2026-09-01&createdOnOrBefore=2026-09-30')),
|
|
256
|
+
).toBe(true),
|
|
257
|
+
);
|
|
258
|
+
// both halves of "touched" are bounded, not just the one, and each against its own column
|
|
259
|
+
const created = auditRequests().filter((url) => url.includes('createdBy=user-1'));
|
|
260
|
+
const voided = auditRequests().filter((url) => url.includes('voidedBy=user-1'));
|
|
261
|
+
expect(created.some((url) => url.includes('createdOnOrAfter=2026-09-01&createdOnOrBefore=2026-09-30'))).toBe(true);
|
|
262
|
+
expect(voided.some((url) => url.includes('voidedOnOrAfter=2026-09-01&voidedOnOrBefore=2026-09-30'))).toBe(true);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('keeps searching everything while only the start of a range has been typed', async () => {
|
|
266
|
+
renderUserEncounters();
|
|
267
|
+
await screen.findByText('Encounters 1–10');
|
|
268
|
+
|
|
269
|
+
await userEvent.type(screen.getByLabelText('Changed between'), '09/01/2026–');
|
|
270
|
+
|
|
271
|
+
expect(screen.getByText('Encounters 1–10')).toBeInTheDocument();
|
|
272
|
+
expect(auditRequests().some((url) => url.includes('createdOnOrAfter=') || url.includes('voidedOnOrAfter='))).toBe(
|
|
273
|
+
false,
|
|
274
|
+
);
|
|
275
|
+
expect(screen.queryByRole('button', { name: /clear filters/i })).not.toBeInTheDocument();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('starts the trail over when the range changes, rather than mixing two searches', async () => {
|
|
279
|
+
mockRestApi({ totalCreated: 5000, obsPerEncounter: 20 });
|
|
280
|
+
renderUserEncounters();
|
|
281
|
+
await screen.findByText('Encounters 1–10');
|
|
282
|
+
await userEvent.click(screen.getByRole('button', { name: 'Next page' }));
|
|
283
|
+
await screen.findByText('Encounters 11–20');
|
|
284
|
+
|
|
285
|
+
await userEvent.clear(screen.getByLabelText('Changed between'));
|
|
286
|
+
await userEvent.type(screen.getByLabelText('Changed between'), septemberRange);
|
|
287
|
+
|
|
288
|
+
// back to the first page, and reading the bounded trail from its start
|
|
289
|
+
expect(await screen.findByText('Encounters 1–10')).toBeInTheDocument();
|
|
290
|
+
await waitFor(() =>
|
|
291
|
+
expect(
|
|
292
|
+
auditRequests().some((url) => url.includes('createdOnOrAfter=2026-09-01') && url.includes('startIndex=0')),
|
|
293
|
+
).toBe(true),
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('offers to clear the range once one is set, and says when nothing falls in it', async () => {
|
|
298
|
+
renderUserEncounters();
|
|
299
|
+
await screen.findByText('Encounters 1–10');
|
|
300
|
+
expect(screen.queryByRole('button', { name: /clear filters/i })).not.toBeInTheDocument();
|
|
301
|
+
|
|
302
|
+
mockRestApi({ totalCreated: 0, obsPerEncounter: 1 });
|
|
303
|
+
await userEvent.clear(screen.getByLabelText('Changed between'));
|
|
304
|
+
await userEvent.type(screen.getByLabelText('Changed between'), septemberRange);
|
|
305
|
+
|
|
306
|
+
expect(await screen.findByText(/did not change any observations matching these filters/i)).toBeInTheDocument();
|
|
307
|
+
await userEvent.click(screen.getByRole('button', { name: /clear filters/i }));
|
|
308
|
+
|
|
309
|
+
expect(screen.queryByRole('button', { name: /clear filters/i })).not.toBeInTheDocument();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('offers every encounter type in use in the system, not just the ones in the trail', async () => {
|
|
313
|
+
renderUserEncounters();
|
|
314
|
+
await screen.findByText('Encounters 1–10');
|
|
315
|
+
|
|
316
|
+
await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
|
|
317
|
+
|
|
318
|
+
const options = within(screen.getByRole('listbox')).getAllByRole('option');
|
|
319
|
+
expect(options.map((option) => option.textContent)).toEqual(['Consultation', 'Vitals', 'Zibra Admission']);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('narrows the list to the chosen encounter type', async () => {
|
|
323
|
+
renderUserEncounters();
|
|
324
|
+
await screen.findByText('Encounters 1–10');
|
|
325
|
+
expect(dataRows()[1]).toHaveTextContent('Patient 1');
|
|
326
|
+
|
|
327
|
+
await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
|
|
328
|
+
await userEvent.click(await screen.findByRole('option', { name: 'Consultation' }));
|
|
329
|
+
|
|
330
|
+
// the fixture alternates types across thirteen encounters, so seven of them are consultations
|
|
331
|
+
await waitFor(() => expect(screen.getByText('Encounters 1–7')).toBeInTheDocument());
|
|
332
|
+
const patients = dataRows().map((row) => row.querySelectorAll('td')[1].textContent);
|
|
333
|
+
expect(patients).toEqual([
|
|
334
|
+
'Patient 0',
|
|
335
|
+
'Patient 2',
|
|
336
|
+
'Patient 4',
|
|
337
|
+
'Patient 6',
|
|
338
|
+
'Patient 8',
|
|
339
|
+
'Patient 10',
|
|
340
|
+
'Patient 12',
|
|
341
|
+
]);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
/** Filling a page of one type takes reading past the encounters of every other type. */
|
|
345
|
+
it('reads further to fill a page of a filtered type', async () => {
|
|
346
|
+
mockRestApi({ totalCreated: 5000, obsPerEncounter: 20 });
|
|
347
|
+
renderUserEncounters();
|
|
348
|
+
await screen.findByText('Encounters 1–10');
|
|
349
|
+
const beforeFilter = auditRequests().length;
|
|
350
|
+
|
|
351
|
+
await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
|
|
352
|
+
await userEvent.click(await screen.findByRole('option', { name: 'Consultation' }));
|
|
353
|
+
|
|
354
|
+
await waitFor(() => expect(dataRows()).toHaveLength(10));
|
|
355
|
+
// a full page of one type needed more of the trail than a full page of any type did
|
|
356
|
+
expect(auditRequests().length).toBeGreaterThan(beforeFilter);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* A filter is applied after the trail is read, so rows trickle in. Arming the gate once the first
|
|
361
|
+
* page is up, then filtering, pins the moment a short list is on screen with more still coming.
|
|
362
|
+
*/
|
|
363
|
+
it('says more encounters are on the way while it reads further', async () => {
|
|
364
|
+
mockRestApi({ totalCreated: 5000, obsPerEncounter: 20 });
|
|
365
|
+
const respondNormally = mockOpenmrsFetch.getMockImplementation();
|
|
366
|
+
|
|
367
|
+
let gateArmed = false;
|
|
368
|
+
let releaseTrail: () => void = () => {};
|
|
369
|
+
const gate = new Promise<void>((resolve) => {
|
|
370
|
+
releaseTrail = resolve;
|
|
371
|
+
});
|
|
372
|
+
mockOpenmrsFetch.mockImplementation((url: string, ...rest) => {
|
|
373
|
+
if (gateArmed && url.includes('/pihapps/obs?')) {
|
|
374
|
+
return gate.then(() => respondNormally(url, ...rest)) as ReturnType<typeof openmrsFetch>;
|
|
375
|
+
}
|
|
376
|
+
return respondNormally(url, ...rest);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
renderUserEncounters();
|
|
380
|
+
await screen.findByText('Encounters 1–10');
|
|
381
|
+
gateArmed = true;
|
|
382
|
+
|
|
383
|
+
await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
|
|
384
|
+
await userEvent.click(await screen.findByRole('option', { name: 'Consultation' }));
|
|
385
|
+
|
|
386
|
+
// rows are already on screen, and the scan is waiting on the next page of the trail
|
|
387
|
+
expect(await screen.findByText('Looking for more encounters…')).toBeInTheDocument();
|
|
388
|
+
expect(dataRows().length).toBeGreaterThan(0);
|
|
389
|
+
|
|
390
|
+
releaseTrail();
|
|
391
|
+
|
|
392
|
+
// the range caption only returns once the page's exact counts have been read too
|
|
393
|
+
expect(await screen.findByText('Encounters 1–10')).toBeInTheDocument();
|
|
394
|
+
expect(dataRows()).toHaveLength(10);
|
|
395
|
+
expect(screen.queryByText('Looking for more encounters…')).not.toBeInTheDocument();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('says so when nothing in the trail matches the filters', async () => {
|
|
399
|
+
renderUserEncounters();
|
|
400
|
+
await screen.findByText('Encounters 1–10');
|
|
401
|
+
|
|
402
|
+
await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i }));
|
|
403
|
+
await userEvent.click(await screen.findByRole('option', { name: 'Zibra Admission' }));
|
|
404
|
+
|
|
405
|
+
expect(await screen.findByText(/did not change any observations matching these filters/i)).toBeInTheDocument();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('drills down into the encounter that is clicked', async () => {
|
|
409
|
+
const { onSelectEncounter } = renderUserEncounters();
|
|
410
|
+
await screen.findByText('Encounters 1–10');
|
|
411
|
+
|
|
412
|
+
await userEvent.click(screen.getAllByRole('button', { name: /2026/ })[0]);
|
|
413
|
+
|
|
414
|
+
expect(onSelectEncounter).toHaveBeenCalledWith('enc-0');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('goes back to the user search', async () => {
|
|
418
|
+
const { onBackToSearch } = renderUserEncounters();
|
|
419
|
+
|
|
420
|
+
await userEvent.click(screen.getByRole('button', { name: /back to user search/i }));
|
|
421
|
+
|
|
422
|
+
expect(onBackToSearch).toHaveBeenCalled();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('says so when the user has changed nothing', async () => {
|
|
426
|
+
mockRestApi({ totalCreated: 0, obsPerEncounter: 1 });
|
|
427
|
+
renderUserEncounters();
|
|
428
|
+
|
|
429
|
+
expect(await screen.findByText(/has not recorded or deleted any observations/i)).toBeInTheDocument();
|
|
430
|
+
});
|
|
431
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
ComboBox,
|
|
6
|
+
DataTableSkeleton,
|
|
7
|
+
InlineLoading,
|
|
8
|
+
InlineNotification,
|
|
9
|
+
Pagination,
|
|
10
|
+
Table,
|
|
11
|
+
TableBody,
|
|
12
|
+
TableCell,
|
|
13
|
+
TableContainer,
|
|
14
|
+
TableHead,
|
|
15
|
+
TableHeader,
|
|
16
|
+
TableRow,
|
|
17
|
+
Tag,
|
|
18
|
+
} from '@carbon/react';
|
|
19
|
+
import { ArrowLeftIcon, ErrorState, OpenmrsDateRangePicker, useConfig } from '@openmrs/esm-framework';
|
|
20
|
+
import { type Config } from '../config-schema';
|
|
21
|
+
import { type OpenmrsResourceRef } from '../types';
|
|
22
|
+
import { formatAuditDatetime } from './audit-format';
|
|
23
|
+
import { type DateRange, fromPickerRange, hasDateRange, toPickerDefault } from './date-range';
|
|
24
|
+
import { useAuditUser, useUsedEncounterTypes, useUserEncounters } from './audit.resource';
|
|
25
|
+
import styles from './audit.scss';
|
|
26
|
+
|
|
27
|
+
interface UserEncountersProps {
|
|
28
|
+
userUuid: string;
|
|
29
|
+
onSelectEncounter(encounterUuid: string): void;
|
|
30
|
+
onBackToSearch(): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The encounters whose observations a user recorded or deleted, as a way into each one's audit
|
|
35
|
+
* trail. Unlike the provider list, this really is "what did this account change" — it is built from
|
|
36
|
+
* the pihapps audit endpoint, which searches observations by their creator and voiding user.
|
|
37
|
+
*/
|
|
38
|
+
export default function UserEncounters({ userUuid, onSelectEncounter, onBackToSearch }: UserEncountersProps) {
|
|
39
|
+
const { t } = useTranslation();
|
|
40
|
+
const config = useConfig<Config>();
|
|
41
|
+
const [pageSize, setPageSize] = useState(config.encountersPageSize ?? 10);
|
|
42
|
+
const pageSizes = useMemo(() => Array.from(new Set([pageSize, 10, 20, 50])).sort((a, b) => a - b), [pageSize]);
|
|
43
|
+
const [dateRange, setDateRange] = useState<DateRange>({});
|
|
44
|
+
// bumped to clear the range picker, which has to be left to hold its own value
|
|
45
|
+
const [datePickerKey, setDatePickerKey] = useState(0);
|
|
46
|
+
const [encounterType, setEncounterType] = useState<OpenmrsResourceRef | null>(null);
|
|
47
|
+
const today = new Date();
|
|
48
|
+
|
|
49
|
+
const { user, error: userError, isLoading: isLoadingUser } = useAuditUser(userUuid);
|
|
50
|
+
const { encounterTypes, isLoading: isLoadingTypes } = useUsedEncounterTypes();
|
|
51
|
+
const {
|
|
52
|
+
activity,
|
|
53
|
+
currentPage,
|
|
54
|
+
goTo,
|
|
55
|
+
firstRowOnPage,
|
|
56
|
+
hasNextPage,
|
|
57
|
+
obsRead,
|
|
58
|
+
stoppedEarly,
|
|
59
|
+
isDiscovering,
|
|
60
|
+
error,
|
|
61
|
+
isLoading,
|
|
62
|
+
} = useUserEncounters(userUuid, pageSize, dateRange, encounterType?.uuid);
|
|
63
|
+
|
|
64
|
+
const hasFilters = hasDateRange(dateRange) || Boolean(encounterType);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className={styles.section}>
|
|
68
|
+
<Button className={styles.backButton} kind="ghost" onClick={onBackToSearch} renderIcon={ArrowLeftIcon} size="sm">
|
|
69
|
+
{t('backToUserSearch', 'Back to user search')}
|
|
70
|
+
</Button>
|
|
71
|
+
<div className={styles.contextHeader}>
|
|
72
|
+
<span className={styles.contextTitle}>{user?.person?.display ?? user?.display ?? ''}</span>
|
|
73
|
+
<span className={styles.contextSubtitle}>{user?.username ?? user?.systemId}</span>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{/*
|
|
77
|
+
The range narrows the trail on the server: the audit endpoint bounds each search against
|
|
78
|
+
its own audit column, so this asks when the account made a change, not when the observation
|
|
79
|
+
it changed was taken.
|
|
80
|
+
*/}
|
|
81
|
+
<div className={styles.filters}>
|
|
82
|
+
{/*
|
|
83
|
+
Every type in use rather than only those this account has touched: encounters are
|
|
84
|
+
discovered lazily, so which types the trail holds is not known until all of it is read.
|
|
85
|
+
*/}
|
|
86
|
+
<ComboBox
|
|
87
|
+
className={styles.filterControl}
|
|
88
|
+
disabled={isLoadingTypes}
|
|
89
|
+
id="user-encounter-type-filter"
|
|
90
|
+
items={encounterTypes}
|
|
91
|
+
itemToString={(type: OpenmrsResourceRef | null) => type?.display ?? ''}
|
|
92
|
+
onChange={({ selectedItem }: { selectedItem: OpenmrsResourceRef | null }) =>
|
|
93
|
+
setEncounterType(selectedItem ?? null)
|
|
94
|
+
}
|
|
95
|
+
placeholder={t('allEncounterTypes', 'All encounter types')}
|
|
96
|
+
selectedItem={encounterType}
|
|
97
|
+
size="sm"
|
|
98
|
+
titleText={t('encounterType', 'Encounter type')}
|
|
99
|
+
/>
|
|
100
|
+
<OpenmrsDateRangePicker
|
|
101
|
+
className={styles.filterControl}
|
|
102
|
+
defaultValue={toPickerDefault(dateRange)}
|
|
103
|
+
id="user-activity-date-range"
|
|
104
|
+
labelText={t('changedBetween', 'Changed between')}
|
|
105
|
+
maxDate={today}
|
|
106
|
+
onChangeRaw={(range) => setDateRange(fromPickerRange(range))}
|
|
107
|
+
key={datePickerKey}
|
|
108
|
+
size="sm"
|
|
109
|
+
/>
|
|
110
|
+
{hasFilters ? (
|
|
111
|
+
<Button
|
|
112
|
+
className={styles.clearFilters}
|
|
113
|
+
kind="ghost"
|
|
114
|
+
onClick={() => {
|
|
115
|
+
setDateRange({});
|
|
116
|
+
setDatePickerKey((key) => key + 1);
|
|
117
|
+
setEncounterType(null);
|
|
118
|
+
}}
|
|
119
|
+
size="sm">
|
|
120
|
+
{t('clearFilters', 'Clear filters')}
|
|
121
|
+
</Button>
|
|
122
|
+
) : null}
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
{userError || error ? (
|
|
126
|
+
<ErrorState error={userError ?? error} headerTitle={t('encounters', 'Encounters')} />
|
|
127
|
+
) : isLoadingUser || isLoading ? (
|
|
128
|
+
<>
|
|
129
|
+
{obsRead > 0 ? (
|
|
130
|
+
<InlineLoading
|
|
131
|
+
description={t('readingUserObservations', 'Read {{obsRead}} observations so far…', { obsRead })}
|
|
132
|
+
/>
|
|
133
|
+
) : null}
|
|
134
|
+
<DataTableSkeleton columnCount={7} compact role="progressbar" showHeader={false} showToolbar={false} />
|
|
135
|
+
</>
|
|
136
|
+
) : activity.length === 0 ? (
|
|
137
|
+
<p className={styles.emptyState}>
|
|
138
|
+
{hasFilters
|
|
139
|
+
? t('noUserEncountersMatch', 'This user did not change any observations matching these filters.')
|
|
140
|
+
: t('noUserEncounters', 'This user has not recorded or deleted any observations.')}
|
|
141
|
+
</p>
|
|
142
|
+
) : (
|
|
143
|
+
<>
|
|
144
|
+
{stoppedEarly ? (
|
|
145
|
+
<InlineNotification
|
|
146
|
+
className={styles.inlineNotification}
|
|
147
|
+
hideCloseButton
|
|
148
|
+
kind="info"
|
|
149
|
+
lowContrast
|
|
150
|
+
subtitle={t(
|
|
151
|
+
'userScanStoppedEarly',
|
|
152
|
+
'Stopped after reading {{obsRead}} observation changes without filling the page. Narrow the date range to search further back.',
|
|
153
|
+
{ obsRead },
|
|
154
|
+
)}
|
|
155
|
+
title={t('partialUserScan', 'Part of the trail')}
|
|
156
|
+
/>
|
|
157
|
+
) : null}
|
|
158
|
+
<TableContainer className={styles.tableContainer}>
|
|
159
|
+
<Table size="sm" useZebraStyles>
|
|
160
|
+
<TableHead>
|
|
161
|
+
<TableRow>
|
|
162
|
+
<TableHeader>{t('lastChanged', 'Last changed')}</TableHeader>
|
|
163
|
+
<TableHeader>{t('patient', 'Patient')}</TableHeader>
|
|
164
|
+
<TableHeader>{t('encounterType', 'Encounter type')}</TableHeader>
|
|
165
|
+
<TableHeader>{t('encounterDate', 'Encounter date')}</TableHeader>
|
|
166
|
+
<TableHeader>{t('location', 'Location')}</TableHeader>
|
|
167
|
+
<TableHeader>{t('obsRecordedCount', 'Observations recorded')}</TableHeader>
|
|
168
|
+
<TableHeader>{t('obsDeletedCount', 'Observations deleted')}</TableHeader>
|
|
169
|
+
</TableRow>
|
|
170
|
+
</TableHead>
|
|
171
|
+
<TableBody>
|
|
172
|
+
{activity.map(({ encounter, obsCreated, obsVoided, lastActivity }) => (
|
|
173
|
+
<TableRow
|
|
174
|
+
className={styles.clickableRow}
|
|
175
|
+
key={encounter.uuid}
|
|
176
|
+
onClick={() => onSelectEncounter(encounter.uuid)}>
|
|
177
|
+
<TableCell>
|
|
178
|
+
<button
|
|
179
|
+
className={styles.linkButton}
|
|
180
|
+
onClick={(event) => {
|
|
181
|
+
event.stopPropagation();
|
|
182
|
+
onSelectEncounter(encounter.uuid);
|
|
183
|
+
}}
|
|
184
|
+
type="button">
|
|
185
|
+
{formatAuditDatetime(lastActivity)}
|
|
186
|
+
</button>
|
|
187
|
+
</TableCell>
|
|
188
|
+
<TableCell>{encounter.patient?.display}</TableCell>
|
|
189
|
+
<TableCell>
|
|
190
|
+
{encounter.encounterType?.display}
|
|
191
|
+
{encounter.voided ? <Tag type="red">{t('deleted', 'Deleted')}</Tag> : null}
|
|
192
|
+
</TableCell>
|
|
193
|
+
<TableCell>{formatAuditDatetime(encounter.encounterDatetime)}</TableCell>
|
|
194
|
+
<TableCell>{encounter.location?.display}</TableCell>
|
|
195
|
+
<TableCell>{obsCreated}</TableCell>
|
|
196
|
+
<TableCell>{obsVoided}</TableCell>
|
|
197
|
+
</TableRow>
|
|
198
|
+
))}
|
|
199
|
+
</TableBody>
|
|
200
|
+
</Table>
|
|
201
|
+
</TableContainer>
|
|
202
|
+
{/*
|
|
203
|
+
A filter is applied after the trail has been read, so matching rows arrive as the scan
|
|
204
|
+
goes on. Without this the list looks finished while it is still filling.
|
|
205
|
+
*/}
|
|
206
|
+
{isDiscovering ? (
|
|
207
|
+
<InlineLoading
|
|
208
|
+
className={styles.discovering}
|
|
209
|
+
description={t('lookingForMoreEncounters', 'Looking for more encounters…')}
|
|
210
|
+
/>
|
|
211
|
+
) : null}
|
|
212
|
+
{/*
|
|
213
|
+
How many encounters this account has touched cannot be known without reading its whole
|
|
214
|
+
trail, which is the very thing being avoided, so there is no total to page against: the
|
|
215
|
+
pager runs with its page count unknown and says only the range on screen.
|
|
216
|
+
*/}
|
|
217
|
+
<Pagination
|
|
218
|
+
isLastPage={!hasNextPage}
|
|
219
|
+
itemText={() =>
|
|
220
|
+
t('encounterRange', 'Encounters {{from}}–{{to}}', {
|
|
221
|
+
from: firstRowOnPage + 1,
|
|
222
|
+
to: firstRowOnPage + activity.length,
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
onChange={({ page: nextPage, pageSize: nextPageSize }) => {
|
|
226
|
+
setPageSize(nextPageSize);
|
|
227
|
+
goTo(nextPage);
|
|
228
|
+
}}
|
|
229
|
+
page={currentPage}
|
|
230
|
+
pageSize={pageSize}
|
|
231
|
+
pageSizes={pageSizes}
|
|
232
|
+
pagesUnknown
|
|
233
|
+
size="sm"
|
|
234
|
+
/>
|
|
235
|
+
</>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|