@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.
Files changed (48) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/README.md +96 -2
  3. package/dist/115.js +1 -1
  4. package/dist/117.js +1 -1
  5. package/dist/117.js.map +1 -1
  6. package/dist/508.js +1 -1
  7. package/dist/508.js.map +1 -1
  8. package/dist/514.js +1 -0
  9. package/dist/514.js.map +1 -0
  10. package/dist/912.js +1 -1
  11. package/dist/912.js.map +1 -1
  12. package/dist/main.js +1 -1
  13. package/dist/pih-esm-audit-app.js +1 -1
  14. package/dist/pih-esm-audit-app.js.buildmanifest.json +34 -34
  15. package/dist/routes.json +1 -1
  16. package/locales/en/translation.json +118 -0
  17. package/locales/fr/translation.json +118 -0
  18. package/package.json +1 -1
  19. package/src/audit/audit-search.component.tsx +58 -0
  20. package/src/audit/audit.component.tsx +63 -16
  21. package/src/audit/audit.resource.ts +445 -10
  22. package/src/audit/audit.scss +6 -0
  23. package/src/audit/date-range-picker.test-stub.tsx +40 -0
  24. package/src/audit/date-range.test.ts +128 -0
  25. package/src/audit/date-range.ts +120 -0
  26. package/src/audit/encounter-filters.component.tsx +40 -18
  27. package/src/audit/encounter-filters.test.ts +1 -2
  28. package/src/audit/encounter-filters.ts +2 -27
  29. package/src/audit/patient-record.component.test.tsx +3 -5
  30. package/src/audit/patient-record.component.tsx +13 -2
  31. package/src/audit/patient-search.component.tsx +1 -1
  32. package/src/audit/provider-encounters.component.test.tsx +254 -0
  33. package/src/audit/provider-encounters.component.tsx +160 -0
  34. package/src/audit/provider-search.component.test.tsx +72 -0
  35. package/src/audit/provider-search.component.tsx +116 -0
  36. package/src/audit/user-encounters.component.test.tsx +431 -0
  37. package/src/audit/user-encounters.component.tsx +239 -0
  38. package/src/audit/user-encounters.test.ts +176 -0
  39. package/src/audit/user-encounters.ts +166 -0
  40. package/src/audit/user-search.component.test.tsx +89 -0
  41. package/src/audit/user-search.component.tsx +111 -0
  42. package/src/root.component.test.tsx +48 -3
  43. package/src/types.ts +34 -0
  44. package/translations/en.json +29 -0
  45. package/translations/es.json +29 -0
  46. package/translations/fr.json +29 -0
  47. package/dist/446.js +0 -1
  48. package/dist/446.js.map +0 -1
@@ -0,0 +1,176 @@
1
+ import { type AuditObs } from '../types';
2
+ import {
3
+ canDiscoverMore,
4
+ countUserObs,
5
+ discoverEncounters,
6
+ emptyStream,
7
+ filterByEncounterType,
8
+ type ObsStream,
9
+ streamToExtend,
10
+ } from './user-encounters';
11
+
12
+ const consultation = { uuid: 'enc-1', encounterType: { uuid: 'type-1', display: 'Oncology Consultation' } };
13
+ const checkin = { uuid: 'enc-2', encounterType: { uuid: 'type-2', display: 'Inscription' } };
14
+ const vitals = { uuid: 'enc-3', encounterType: { uuid: 'type-3', display: 'Vitals' } };
15
+
16
+ /** A day in September 2026, as the REST API would report it. */
17
+ function day(dayOfMonth: number): string {
18
+ return `2026-09-${String(dayOfMonth).padStart(2, '0')}T08:00:00.000+0000`;
19
+ }
20
+
21
+ function created(uuid: string, encounter: unknown, when: string): AuditObs {
22
+ return { uuid, encounter: encounter as AuditObs['encounter'], auditInfo: { dateCreated: when } };
23
+ }
24
+
25
+ function voided(uuid: string, encounter: unknown, when: string): AuditObs {
26
+ return { uuid, encounter: encounter as AuditObs['encounter'], auditInfo: { dateVoided: when } };
27
+ }
28
+
29
+ function stream(obs: Array<AuditObs>, exhausted = true): ObsStream {
30
+ return { obs, exhausted };
31
+ }
32
+
33
+ /** A search that returned nothing and has nothing more to give. */
34
+ const exhaustedStream: ObsStream = { obs: [], exhausted: true };
35
+
36
+ describe('discoverEncounters', () => {
37
+ it('collapses the observations of one encounter into a single row', () => {
38
+ const activity = discoverEncounters(
39
+ stream([created('obs-1', consultation, day(1)), created('obs-2', consultation, day(2))]),
40
+ exhaustedStream,
41
+ );
42
+
43
+ expect(activity).toHaveLength(1);
44
+ expect(activity[0].encounter.uuid).toBe('enc-1');
45
+ });
46
+
47
+ it('takes the most recent change as an encounter’s last activity', () => {
48
+ const activity = discoverEncounters(
49
+ stream([created('obs-1', consultation, day(1))]),
50
+ stream([voided('obs-2', consultation, day(7))]),
51
+ );
52
+
53
+ expect(activity[0].lastActivity).toBe(day(7));
54
+ });
55
+
56
+ it('merges the two searches into one order, most recently changed first', () => {
57
+ const activity = discoverEncounters(
58
+ stream([created('obs-1', vitals, day(6)), created('obs-2', consultation, day(2))]),
59
+ stream([voided('obs-3', checkin, day(4))]),
60
+ );
61
+
62
+ expect(activity.map((entry) => entry.encounter.uuid)).toEqual(['enc-3', 'enc-2', 'enc-1']);
63
+ });
64
+
65
+ it('leaves out observations that belong to no encounter, having no trail to open', () => {
66
+ expect(discoverEncounters(stream([created('obs-1', undefined, day(1))]), exhaustedStream)).toEqual([]);
67
+ });
68
+
69
+ /**
70
+ * The point of reading lazily: a partly-read stream must not offer an encounter whose place a row
71
+ * further down could still take, or the first page would reshuffle as the second was read.
72
+ */
73
+ it('withholds encounters that an unread row could still outrank', () => {
74
+ const partial = stream([created('obs-1', consultation, day(9)), created('obs-2', checkin, day(5))], false);
75
+
76
+ const activity = discoverEncounters(partial, exhaustedStream);
77
+
78
+ // enc-2 sits at the boundary, so only enc-1 is certainly placed
79
+ expect(activity.map((entry) => entry.encounter.uuid)).toEqual(['enc-1']);
80
+ });
81
+
82
+ it('offers everything once both searches are exhausted', () => {
83
+ const activity = discoverEncounters(
84
+ stream([created('obs-1', consultation, day(9)), created('obs-2', checkin, day(5))]),
85
+ exhaustedStream,
86
+ );
87
+
88
+ expect(activity.map((entry) => entry.encounter.uuid)).toEqual(['enc-1', 'enc-2']);
89
+ });
90
+
91
+ it('trusts nothing while a search has not been read at all', () => {
92
+ const activity = discoverEncounters(stream([created('obs-1', consultation, day(9))]), emptyStream);
93
+
94
+ // the voided search is unread rather than exhausted, so its first row could outrank enc-1
95
+ expect(activity).toEqual([]);
96
+ });
97
+
98
+ it('trusts only as far as the search that has been read least far', () => {
99
+ const activity = discoverEncounters(
100
+ stream([created('obs-1', consultation, day(9))], false),
101
+ stream([voided('obs-2', checkin, day(3))], false),
102
+ );
103
+
104
+ // the created search is read only to day 9, so nothing at or below that is certain yet
105
+ expect(activity).toEqual([]);
106
+ });
107
+ });
108
+
109
+ describe('streamToExtend', () => {
110
+ it('reads from whichever search is holding the boundary up', () => {
111
+ // read only as far as day 9, so this one is holding the boundary up
112
+ const createdBehind = stream([created('obs-1', consultation, day(9))], false);
113
+ const voidedAhead = stream([voided('obs-2', checkin, day(3))], false);
114
+ expect(streamToExtend(createdBehind, voidedAhead)).toBe('created');
115
+
116
+ // and the other way round
117
+ const createdAhead = stream([created('obs-3', checkin, day(3))], false);
118
+ const voidedBehind = stream([voided('obs-4', consultation, day(9))], false);
119
+ expect(streamToExtend(createdAhead, voidedBehind)).toBe('voided');
120
+ });
121
+
122
+ it('reads from the other one when a search is exhausted', () => {
123
+ expect(streamToExtend(stream([], true), stream([], false))).toBe('voided');
124
+ expect(streamToExtend(stream([], false), stream([], true))).toBe('created');
125
+ });
126
+
127
+ it('has nothing left to read once both are exhausted', () => {
128
+ expect(streamToExtend(stream([], true), stream([], true))).toBeNull();
129
+ expect(canDiscoverMore(stream([], true), stream([], true))).toBe(false);
130
+ expect(canDiscoverMore(stream([], true), stream([], false))).toBe(true);
131
+ });
132
+ });
133
+
134
+ describe('countUserObs', () => {
135
+ const user = 'user-1';
136
+ const other = 'user-2';
137
+ const obs = (creator: string, voidedBy?: string): AuditObs => ({
138
+ uuid: `obs-${Math.random()}`,
139
+ auditInfo: {
140
+ creator: { uuid: creator },
141
+ ...(voidedBy ? { voidedBy: { uuid: voidedBy } } : {}),
142
+ },
143
+ });
144
+
145
+ it('counts what the user recorded and what they deleted', () => {
146
+ expect(countUserObs([obs(user), obs(user), obs(other, user)], user)).toEqual({ obsCreated: 2, obsVoided: 1 });
147
+ });
148
+
149
+ it('counts an observation the user both recorded and deleted in each column', () => {
150
+ expect(countUserObs([obs(user, user)], user)).toEqual({ obsCreated: 1, obsVoided: 1 });
151
+ });
152
+
153
+ it('ignores what other users did to the same encounter', () => {
154
+ expect(countUserObs([obs(other), obs(other, other)], user)).toEqual({ obsCreated: 0, obsVoided: 0 });
155
+ });
156
+ });
157
+
158
+ describe('filterByEncounterType', () => {
159
+ const activity = [
160
+ { encounter: consultation, lastActivity: day(9) },
161
+ { encounter: checkin, lastActivity: day(5) },
162
+ { encounter: { uuid: 'enc-4' }, lastActivity: day(4) },
163
+ ];
164
+
165
+ it('keeps everything when no type is chosen', () => {
166
+ expect(filterByEncounterType(activity, undefined)).toBe(activity);
167
+ });
168
+
169
+ it('keeps only encounters of the chosen type', () => {
170
+ expect(filterByEncounterType(activity, 'type-1').map((entry) => entry.encounter.uuid)).toEqual(['enc-1']);
171
+ });
172
+
173
+ it('leaves out an encounter whose type the api did not report', () => {
174
+ expect(filterByEncounterType(activity, 'type-3')).toEqual([]);
175
+ });
176
+ });
@@ -0,0 +1,166 @@
1
+ import { type AuditEncounter, type AuditObs } from '../types';
2
+
3
+ /** Which audit action a stream of observations was searched by. */
4
+ export type AuditAction = 'created' | 'voided';
5
+
6
+ /** What has been read of one of the two audit searches so far. */
7
+ export interface ObsStream {
8
+ /** Rows read so far, in the order the endpoint returned them: audit date descending. */
9
+ obs: Array<AuditObs>;
10
+ /** True once a short page came back, meaning there is nothing further to read. */
11
+ exhausted: boolean;
12
+ }
13
+
14
+ export const emptyStream: ObsStream = { obs: [], exhausted: false };
15
+
16
+ /** What one user did to the observations of one encounter. */
17
+ export interface UserEncounterActivity {
18
+ encounter: AuditEncounter;
19
+ /** The most recent action, which is what the list is ordered by. */
20
+ lastActivity: string;
21
+ /** How many of the encounter's observations the user recorded, once counted exactly. */
22
+ obsCreated?: number;
23
+ /** How many of the encounter's observations the user deleted, once counted exactly. */
24
+ obsVoided?: number;
25
+ }
26
+
27
+ /** When the audit action behind an observation happened, in the stream it came from. */
28
+ export function actionTime(obs: AuditObs, action: AuditAction): string {
29
+ return (action === 'created' ? obs.auditInfo?.dateCreated : obs.auditInfo?.dateVoided) ?? '';
30
+ }
31
+
32
+ /**
33
+ * Sorts after any ISO date, standing for a stream whose position is not yet known at all: one that
34
+ * has been read no further than its start and still has rows to come. Such a stream constrains
35
+ * everything, since its very first unread row could outrank anything already discovered.
36
+ */
37
+ const unreadWatermark = '\uffff';
38
+
39
+ /**
40
+ * How far down a stream has been read, as the audit date of its last row.
41
+ *
42
+ * `null` means the stream constrains nothing, because it is exhausted. That is deliberately
43
+ * different from a stream that has simply not been read yet, which constrains everything — a
44
+ * distinction worth keeping, since conflating the two would let the list trust an order that a
45
+ * search nobody has run could still overturn.
46
+ */
47
+ function watermark(stream: ObsStream, action: AuditAction): string | null {
48
+ if (stream.exhausted) {
49
+ return null;
50
+ }
51
+ if (stream.obs.length === 0) {
52
+ return unreadWatermark;
53
+ }
54
+ return actionTime(stream.obs[stream.obs.length - 1], action) || unreadWatermark;
55
+ }
56
+
57
+ /** How far the merged order can be trusted, or null when both streams are exhausted. */
58
+ function discoveryBoundary(created: ObsStream, voided: ObsStream): string | null {
59
+ const marks = [watermark(created, 'created'), watermark(voided, 'voided')].filter(
60
+ (mark): mark is string => mark !== null,
61
+ );
62
+ return marks.length === 0 ? null : marks.sort().pop();
63
+ }
64
+
65
+ /**
66
+ * The stream to read from next, being whichever is holding the discovery boundary up. Reading the
67
+ * other one cannot reveal an encounter that belongs higher in the list.
68
+ */
69
+ export function streamToExtend(created: ObsStream, voided: ObsStream): AuditAction | null {
70
+ if (created.exhausted && voided.exhausted) {
71
+ return null;
72
+ }
73
+ if (created.exhausted) {
74
+ return 'voided';
75
+ }
76
+ if (voided.exhausted) {
77
+ return 'created';
78
+ }
79
+ return (watermark(created, 'created') ?? '') >= (watermark(voided, 'voided') ?? '') ? 'created' : 'voided';
80
+ }
81
+
82
+ /**
83
+ * The encounters discoverable from what has been read, most recently changed first.
84
+ *
85
+ * Both searches return observations by audit date descending, so the first time an encounter
86
+ * appears in the merged stream is its most recent change — which means encounters come out of the
87
+ * merge already in the order the list wants them, without having to read to the end of either
88
+ * search.
89
+ *
90
+ * What has *not* been read still constrains the answer. An observation not yet read can only be
91
+ * older than the last row read from its stream, so any encounter first seen later than both
92
+ * streams' positions is certain to be correctly placed; one at or below that boundary might still
93
+ * be overtaken by a row further down. Only the certain ones are returned, so a page never reorders
94
+ * itself as more is read.
95
+ */
96
+ export function discoverEncounters(created: ObsStream, voided: ObsStream): Array<UserEncounterActivity> {
97
+ const boundary = discoveryBoundary(created, voided);
98
+
99
+ const firstSeen = new Map<string, UserEncounterActivity>();
100
+ const consider = (stream: ObsStream, action: AuditAction) => {
101
+ for (const obs of stream.obs) {
102
+ const encounter = obs.encounter as AuditEncounter | undefined;
103
+ // An observation can exist outside an encounter, and there is no encounter audit trail to
104
+ // show for one, so it cannot appear in a list of encounters.
105
+ if (!encounter?.uuid) {
106
+ continue;
107
+ }
108
+ const when = actionTime(obs, action);
109
+ const existing = firstSeen.get(encounter.uuid);
110
+ if (!existing) {
111
+ firstSeen.set(encounter.uuid, { encounter, lastActivity: when });
112
+ } else if (when > existing.lastActivity) {
113
+ existing.lastActivity = when;
114
+ }
115
+ }
116
+ };
117
+ consider(created, 'created');
118
+ consider(voided, 'voided');
119
+
120
+ return Array.from(firstSeen.values())
121
+ .filter((activity) => boundary === null || activity.lastActivity > boundary)
122
+ .sort((a, b) => b.lastActivity.localeCompare(a.lastActivity) || a.encounter.uuid.localeCompare(b.encounter.uuid));
123
+ }
124
+
125
+ /**
126
+ * Narrows discovered encounters to one encounter type.
127
+ *
128
+ * The audit endpoint takes no encounter type parameter, so this is applied after the merge rather
129
+ * than by the server. That means a rare type may need much of the trail read before a page's worth
130
+ * of it is found, which is why the scan reports when it stopped early.
131
+ */
132
+ export function filterByEncounterType(
133
+ activity: Array<UserEncounterActivity>,
134
+ encounterTypeUuid: string | undefined,
135
+ ): Array<UserEncounterActivity> {
136
+ if (!encounterTypeUuid) {
137
+ return activity;
138
+ }
139
+ return activity.filter((entry) => entry.encounter.encounterType?.uuid === encounterTypeUuid);
140
+ }
141
+
142
+ /** Whether reading further could reveal more encounters. */
143
+ export function canDiscoverMore(created: ObsStream, voided: ObsStream): boolean {
144
+ return !created.exhausted || !voided.exhausted;
145
+ }
146
+
147
+ /**
148
+ * How many of an encounter's observations the given user recorded and deleted.
149
+ *
150
+ * The audit search cannot be narrowed to one encounter, so the counts come from reading the
151
+ * encounter's own observations and attributing them — which is exact, and only done for the rows
152
+ * actually on screen.
153
+ */
154
+ export function countUserObs(obsList: Array<AuditObs>, userUuid: string): { obsCreated: number; obsVoided: number } {
155
+ let obsCreated = 0;
156
+ let obsVoided = 0;
157
+ for (const obs of obsList) {
158
+ if (obs.auditInfo?.creator?.uuid === userUuid) {
159
+ obsCreated += 1;
160
+ }
161
+ if (obs.auditInfo?.voidedBy?.uuid === userUuid) {
162
+ obsVoided += 1;
163
+ }
164
+ }
165
+ return { obsCreated, obsVoided };
166
+ }
@@ -0,0 +1,89 @@
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 UserSearch from './user-search.component';
7
+
8
+ const mockOpenmrsFetch = jest.mocked(openmrsFetch);
9
+
10
+ const mockUsers = [
11
+ {
12
+ uuid: 'user-1',
13
+ display: 'butch',
14
+ username: 'butch',
15
+ systemId: '3-4',
16
+ person: { uuid: 'person-1', display: 'Cos John' },
17
+ },
18
+ {
19
+ uuid: 'user-2',
20
+ display: 'retired.clerk',
21
+ username: 'retired.clerk',
22
+ retired: true,
23
+ person: { uuid: 'person-2', display: 'Old Clerk' },
24
+ },
25
+ ];
26
+
27
+ function renderUserSearch() {
28
+ const onSelectUser = jest.fn();
29
+ render(
30
+ <SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
31
+ <UserSearch onSelectUser={onSelectUser} />
32
+ </SWRConfig>,
33
+ );
34
+ return onSelectUser;
35
+ }
36
+
37
+ describe('<UserSearch />', () => {
38
+ beforeEach(() => {
39
+ mockOpenmrsFetch.mockResolvedValue({
40
+ data: { results: mockUsers, totalCount: 2 },
41
+ } as unknown as ReturnType<typeof openmrsFetch>);
42
+ });
43
+
44
+ it('asks for a search term before searching', () => {
45
+ renderUserSearch();
46
+
47
+ expect(screen.getByText(/enter a name or username/i)).toBeInTheDocument();
48
+ expect(mockOpenmrsFetch).not.toHaveBeenCalled();
49
+ });
50
+
51
+ it('searches users by name or username and lists what it finds', async () => {
52
+ renderUserSearch();
53
+
54
+ await userEvent.type(screen.getByRole('searchbox'), 'butch');
55
+
56
+ expect(await screen.findByRole('button', { name: 'Cos John' })).toBeInTheDocument();
57
+ expect(screen.getByRole('cell', { name: 'butch' })).toBeInTheDocument();
58
+ expect(mockOpenmrsFetch).toHaveBeenCalledWith(expect.stringContaining('/ws/rest/v1/user?q=butch'));
59
+ });
60
+
61
+ it('marks a retired account, since it can still own an audit trail', async () => {
62
+ renderUserSearch();
63
+
64
+ await userEvent.type(screen.getByRole('searchbox'), 'clerk');
65
+
66
+ const retiredRow = (await screen.findAllByRole('row')).find((row) => row.textContent?.includes('Old Clerk'));
67
+ expect(retiredRow).toHaveTextContent('Retired');
68
+ });
69
+
70
+ it('drills down into the user that is clicked', async () => {
71
+ const onSelectUser = renderUserSearch();
72
+
73
+ await userEvent.type(screen.getByRole('searchbox'), 'butch');
74
+ await userEvent.click(await screen.findByRole('button', { name: 'Cos John' }));
75
+
76
+ expect(onSelectUser).toHaveBeenCalledWith('user-1');
77
+ });
78
+
79
+ it('says so when nothing matches', async () => {
80
+ mockOpenmrsFetch.mockResolvedValue({ data: { results: [], totalCount: 0 } } as unknown as ReturnType<
81
+ typeof openmrsFetch
82
+ >);
83
+ renderUserSearch();
84
+
85
+ await userEvent.type(screen.getByRole('searchbox'), 'nobody');
86
+
87
+ expect(await screen.findByText(/no users match "nobody"/i)).toBeInTheDocument();
88
+ });
89
+ });
@@ -0,0 +1,111 @@
1
+ import React, { useEffect, useMemo, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import {
4
+ DataTableSkeleton,
5
+ Pagination,
6
+ Search,
7
+ Table,
8
+ TableBody,
9
+ TableCell,
10
+ TableContainer,
11
+ TableHead,
12
+ TableHeader,
13
+ TableRow,
14
+ Tag,
15
+ } from '@carbon/react';
16
+ import { ErrorState, useConfig, useDebounce } from '@openmrs/esm-framework';
17
+ import { type Config } from '../config-schema';
18
+ import { useUserSearch } from './audit.resource';
19
+ import styles from './audit.scss';
20
+
21
+ interface UserSearchProps {
22
+ onSelectUser(userUuid: string): void;
23
+ }
24
+
25
+ /**
26
+ * Finds the user account whose work is being audited, by username or by the person's name.
27
+ */
28
+ export default function UserSearch({ onSelectUser }: UserSearchProps) {
29
+ const { t } = useTranslation();
30
+ const config = useConfig<Config>();
31
+ const [searchTerm, setSearchTerm] = useState('');
32
+ const [page, setPage] = useState(1);
33
+ const [pageSize, setPageSize] = useState(config.patientSearchPageSize ?? 10);
34
+ const debouncedSearchTerm = useDebounce(searchTerm.trim(), 300);
35
+ const pageSizes = useMemo(() => Array.from(new Set([pageSize, 10, 20, 50])).sort((a, b) => a - b), [pageSize]);
36
+ const { users, totalCount, error, isLoading } = useUserSearch(debouncedSearchTerm, page, pageSize);
37
+
38
+ useEffect(() => {
39
+ setPage(1);
40
+ }, [debouncedSearchTerm]);
41
+
42
+ return (
43
+ <div>
44
+ <Search
45
+ className={styles.searchInput}
46
+ labelText={t('searchForUser', 'Search for a user by name or username')}
47
+ onChange={(event) => setSearchTerm(event.target.value)}
48
+ placeholder={t('searchForUser', 'Search for a user by name or username')}
49
+ size="lg"
50
+ value={searchTerm}
51
+ />
52
+ {!debouncedSearchTerm ? (
53
+ <p className={styles.emptyState}>
54
+ {t('searchUserToBegin', 'Enter a name or username to see the encounters that user has modified.')}
55
+ </p>
56
+ ) : error ? (
57
+ <ErrorState error={error} headerTitle={t('userSearchResults', 'User search results')} />
58
+ ) : isLoading ? (
59
+ <DataTableSkeleton columnCount={3} compact role="progressbar" showHeader={false} showToolbar={false} />
60
+ ) : users.length === 0 ? (
61
+ <p className={styles.emptyState}>
62
+ {t('noUsersFound', 'No users match "{{searchTerm}}".', { searchTerm: debouncedSearchTerm })}
63
+ </p>
64
+ ) : (
65
+ <>
66
+ <TableContainer className={styles.tableContainer}>
67
+ <Table size="sm" useZebraStyles>
68
+ <TableHead>
69
+ <TableRow>
70
+ <TableHeader>{t('name', 'Name')}</TableHeader>
71
+ <TableHeader>{t('username', 'Username')}</TableHeader>
72
+ <TableHeader>{t('status', 'Status')}</TableHeader>
73
+ </TableRow>
74
+ </TableHead>
75
+ <TableBody>
76
+ {users.map((user) => (
77
+ <TableRow className={styles.clickableRow} key={user.uuid} onClick={() => onSelectUser(user.uuid)}>
78
+ <TableCell>
79
+ <button
80
+ className={styles.linkButton}
81
+ onClick={(event) => {
82
+ event.stopPropagation();
83
+ onSelectUser(user.uuid);
84
+ }}
85
+ type="button">
86
+ {user.person?.display ?? user.display}
87
+ </button>
88
+ </TableCell>
89
+ <TableCell>{user.username ?? user.systemId}</TableCell>
90
+ <TableCell>{user.retired ? <Tag type="gray">{t('retired', 'Retired')}</Tag> : null}</TableCell>
91
+ </TableRow>
92
+ ))}
93
+ </TableBody>
94
+ </Table>
95
+ </TableContainer>
96
+ <Pagination
97
+ onChange={({ page: nextPage, pageSize: nextPageSize }) => {
98
+ setPage(nextPage);
99
+ setPageSize(nextPageSize);
100
+ }}
101
+ page={page}
102
+ pageSize={pageSize}
103
+ pageSizes={pageSizes}
104
+ size="sm"
105
+ totalItems={totalCount}
106
+ />
107
+ </>
108
+ )}
109
+ </div>
110
+ );
111
+ }
@@ -1,11 +1,11 @@
1
1
  import React from 'react';
2
2
  import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
3
4
  import { SWRConfig } from 'swr';
4
- import { openmrsFetch, useConfig } from '@openmrs/esm-framework';
5
+ import { openmrsFetch } from '@openmrs/esm-framework';
5
6
  import Root from './root.component';
6
7
 
7
8
  const mockOpenmrsFetch = jest.mocked(openmrsFetch);
8
- const mockUseConfig = jest.mocked(useConfig);
9
9
 
10
10
  function renderRoot(url = '/') {
11
11
  window.history.pushState({}, '', url);
@@ -18,13 +18,28 @@ function renderRoot(url = '/') {
18
18
 
19
19
  describe('<Root />', () => {
20
20
  beforeEach(() => {
21
- mockUseConfig.mockReturnValue({ patientChartUrl: '${openmrsSpaBase}/patient/${patientUuid}/chart' });
22
21
  mockOpenmrsFetch.mockImplementation((url: string) => {
23
22
  if (url.includes('/patient/patient-1')) {
24
23
  return Promise.resolve({
25
24
  data: { uuid: 'patient-1', display: 'Y2AHXV - Dave TestPatient', person: { display: 'Dave TestPatient' } },
26
25
  }) as ReturnType<typeof openmrsFetch>;
27
26
  }
27
+ if (url.includes('/pihapps/obs?')) {
28
+ return Promise.resolve({ data: { results: [] } }) as ReturnType<typeof openmrsFetch>;
29
+ }
30
+ if (url.includes('/user/user-1')) {
31
+ return Promise.resolve({
32
+ data: { uuid: 'user-1', display: 'butch', username: 'butch', person: { display: 'Cos John' } },
33
+ }) as ReturnType<typeof openmrsFetch>;
34
+ }
35
+ if (url.includes('/provider/prov-1')) {
36
+ return Promise.resolve({
37
+ data: { uuid: 'prov-1', display: 'MCPRPG - Louidor Jean paul', identifier: 'MCPRPG' },
38
+ }) as ReturnType<typeof openmrsFetch>;
39
+ }
40
+ if (url.includes('/pihapps/encounter?')) {
41
+ return Promise.resolve({ data: { results: [], totalCount: 0 } }) as ReturnType<typeof openmrsFetch>;
42
+ }
28
43
  if (url.includes('/encounter/enc-1')) {
29
44
  return Promise.resolve({
30
45
  data: { uuid: 'enc-1', encounterType: { uuid: 'type-1', display: 'Oncology Consultation' } },
@@ -42,6 +57,36 @@ describe('<Root />', () => {
42
57
  expect(screen.getByText(/enter a patient name or identifier/i)).toBeInTheDocument();
43
58
  });
44
59
 
60
+ it('can search by user instead', async () => {
61
+ renderRoot();
62
+
63
+ await userEvent.click(screen.getByRole('tab', { name: 'Users' }));
64
+
65
+ expect(screen.getByText(/enter a name or username/i)).toBeInTheDocument();
66
+ });
67
+
68
+ it("shows a user's modified encounters when the url names a user", async () => {
69
+ renderRoot('/?user=user-1');
70
+
71
+ expect(await screen.findByText('Cos John')).toBeInTheDocument();
72
+ expect(screen.getByRole('button', { name: /back to user search/i })).toBeInTheDocument();
73
+ });
74
+
75
+ it('can search by provider instead', async () => {
76
+ renderRoot();
77
+
78
+ await userEvent.click(screen.getByRole('tab', { name: 'Providers' }));
79
+
80
+ expect(screen.getByText(/enter a provider name or identifier/i)).toBeInTheDocument();
81
+ });
82
+
83
+ it("shows a provider's encounters when the url names a provider", async () => {
84
+ renderRoot('/?provider=prov-1');
85
+
86
+ expect(await screen.findByText('MCPRPG')).toBeInTheDocument();
87
+ expect(screen.getByRole('button', { name: /back to provider search/i })).toBeInTheDocument();
88
+ });
89
+
45
90
  it("shows a patient's encounters when the url names a patient", async () => {
46
91
  renderRoot('/?patient=patient-1');
47
92
 
package/src/types.ts CHANGED
@@ -49,6 +49,35 @@ export interface AuditPatient {
49
49
  };
50
50
  }
51
51
 
52
+ /**
53
+ * A user account, as returned by the REST user search. `display` is the username (or system id),
54
+ * while the person's real name lives on `person`.
55
+ */
56
+ export interface AuditUser {
57
+ uuid: string;
58
+ display?: string;
59
+ username?: string;
60
+ systemId?: string;
61
+ retired?: boolean;
62
+ person?: {
63
+ uuid?: string;
64
+ display?: string;
65
+ };
66
+ }
67
+
68
+ /** A provider, as returned by the REST provider search. */
69
+ export interface AuditProvider {
70
+ uuid: string;
71
+ display?: string;
72
+ identifier?: string;
73
+ retired?: boolean;
74
+ person?: {
75
+ uuid?: string;
76
+ display?: string;
77
+ gender?: string;
78
+ };
79
+ }
80
+
52
81
  export interface EncounterProvider {
53
82
  uuid: string;
54
83
  voided?: boolean;
@@ -92,6 +121,11 @@ export interface AuditObs {
92
121
  concept?: OpenmrsResourceRef & { descriptions?: Array<ConceptDescription> };
93
122
  /** Set when this obs is a member of an obs group. */
94
123
  obsGroup?: OpenmrsResourceRef;
124
+ /**
125
+ * A reference by default, but the audit endpoint is asked for the whole encounter so that the
126
+ * observations a user touched can be grouped into the encounters they belong to.
127
+ */
128
+ encounter?: OpenmrsResourceRef | AuditEncounter;
95
129
  /** Set when this obs replaced an earlier one, i.e. when the value was edited. */
96
130
  previousVersion?: OpenmrsResourceRef;
97
131
  auditInfo?: AuditInfo;