@openmrs/esm-react-utils 8.0.1-pre.3511 → 8.0.1-pre.3525
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/dist/useVisit.js +4 -1
- package/package.json +15 -13
- package/src/UserHasAccess.test.tsx +311 -0
- package/src/usePatient.test.ts +433 -0
- package/src/useVisit.test.ts +505 -0
- package/src/useVisit.ts +5 -1
- /package/src/{useOpenmrsFetchAll.test.tsx → useOpenmrsFetchAll.test.ts} +0 -0
- /package/src/{useOpenmrsInfinite.test.tsx → useOpenmrsInfinite.test.ts} +0 -0
- /package/src/{useOpenmrsPagination.test.tsx → useOpenmrsPagination.test.ts} +0 -0
package/.turbo/turbo-build.log
CHANGED
package/dist/useVisit.js
CHANGED
|
@@ -70,6 +70,9 @@ dayjs.extend(isToday);
|
|
|
70
70
|
retroMutate
|
|
71
71
|
]);
|
|
72
72
|
useVisitContextStore(mutateVisit);
|
|
73
|
+
const waitingForData = Boolean(!activeData || retrospectiveVisitUuid && !retroData);
|
|
74
|
+
const hasRelevantError = Boolean(retrospectiveVisitUuid ? retroError : activeError);
|
|
75
|
+
const isLoading = waitingForData && !hasRelevantError;
|
|
73
76
|
return {
|
|
74
77
|
error: activeError || retroError,
|
|
75
78
|
mutate: mutateVisit,
|
|
@@ -77,6 +80,6 @@ dayjs.extend(isToday);
|
|
|
77
80
|
activeVisit,
|
|
78
81
|
currentVisit,
|
|
79
82
|
currentVisitIsRetrospective: Boolean(retrospectiveVisitUuid),
|
|
80
|
-
isLoading
|
|
83
|
+
isLoading
|
|
81
84
|
};
|
|
82
85
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openmrs/esm-react-utils",
|
|
3
|
-
"version": "8.0.1-pre.
|
|
3
|
+
"version": "8.0.1-pre.3525",
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
5
|
"description": "React utilities for OpenMRS.",
|
|
6
6
|
"type": "module",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"source": true,
|
|
24
24
|
"scripts": {
|
|
25
25
|
"test": "cross-env TZ=UTC vitest run --passWithNoTests",
|
|
26
|
+
"coverage": "cross-env TZ=UTC vitest run --coverage --passWithNoTests",
|
|
26
27
|
"build": "rimraf dist && concurrently \"swc --strip-leading-paths src -d dist\" \"tsc --project tsconfig.build.json\"",
|
|
27
28
|
"build:development": "rimraf dist && concurrently \"swc --strip-leading-paths src -d dist\" \"tsc --project tsconfig.build.json\"",
|
|
28
29
|
"typescript": "tsc --project tsconfig.build.json",
|
|
@@ -78,19 +79,20 @@
|
|
|
78
79
|
"swr": "2.x"
|
|
79
80
|
},
|
|
80
81
|
"devDependencies": {
|
|
81
|
-
"@openmrs/esm-api": "8.0.1-pre.
|
|
82
|
-
"@openmrs/esm-config": "8.0.1-pre.
|
|
83
|
-
"@openmrs/esm-context": "8.0.1-pre.
|
|
84
|
-
"@openmrs/esm-emr-api": "8.0.1-pre.
|
|
85
|
-
"@openmrs/esm-error-handling": "8.0.1-pre.
|
|
86
|
-
"@openmrs/esm-extensions": "8.0.1-pre.
|
|
87
|
-
"@openmrs/esm-feature-flags": "8.0.1-pre.
|
|
88
|
-
"@openmrs/esm-globals": "8.0.1-pre.
|
|
89
|
-
"@openmrs/esm-navigation": "8.0.1-pre.
|
|
90
|
-
"@openmrs/esm-state": "8.0.1-pre.
|
|
91
|
-
"@openmrs/esm-utils": "8.0.1-pre.
|
|
82
|
+
"@openmrs/esm-api": "8.0.1-pre.3525",
|
|
83
|
+
"@openmrs/esm-config": "8.0.1-pre.3525",
|
|
84
|
+
"@openmrs/esm-context": "8.0.1-pre.3525",
|
|
85
|
+
"@openmrs/esm-emr-api": "8.0.1-pre.3525",
|
|
86
|
+
"@openmrs/esm-error-handling": "8.0.1-pre.3525",
|
|
87
|
+
"@openmrs/esm-extensions": "8.0.1-pre.3525",
|
|
88
|
+
"@openmrs/esm-feature-flags": "8.0.1-pre.3525",
|
|
89
|
+
"@openmrs/esm-globals": "8.0.1-pre.3525",
|
|
90
|
+
"@openmrs/esm-navigation": "8.0.1-pre.3525",
|
|
91
|
+
"@openmrs/esm-state": "8.0.1-pre.3525",
|
|
92
|
+
"@openmrs/esm-utils": "8.0.1-pre.3525",
|
|
92
93
|
"@swc/cli": "^0.7.7",
|
|
93
94
|
"@swc/core": "^1.11.29",
|
|
95
|
+
"@vitest/coverage-v8": "^4.0.7",
|
|
94
96
|
"concurrently": "^9.1.2",
|
|
95
97
|
"cross-env": "^7.0.3",
|
|
96
98
|
"dayjs": "^1.11.13",
|
|
@@ -102,7 +104,7 @@
|
|
|
102
104
|
"rimraf": "^6.0.1",
|
|
103
105
|
"rxjs": "^6.5.3",
|
|
104
106
|
"swr": "2.2.5",
|
|
105
|
-
"vitest": "^
|
|
107
|
+
"vitest": "^4.0.7"
|
|
106
108
|
},
|
|
107
109
|
"stableVersion": "8.0.0"
|
|
108
110
|
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { Observable, type Subscriber } from 'rxjs';
|
|
5
|
+
import type { LoggedInUser, Privilege, Role } from '@openmrs/esm-api';
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
|
7
|
+
import { getCurrentUser, userHasAccess } from '@openmrs/esm-api';
|
|
8
|
+
import { UserHasAccess } from './UserHasAccess';
|
|
9
|
+
|
|
10
|
+
// Mock getCurrentUser and userHasAccess
|
|
11
|
+
const mockGetCurrentUser = vi.fn();
|
|
12
|
+
const mockUserHasAccess = vi.fn();
|
|
13
|
+
|
|
14
|
+
vi.mock('@openmrs/esm-api', () => ({
|
|
15
|
+
getCurrentUser: (...args: Parameters<typeof getCurrentUser>) => mockGetCurrentUser(...args),
|
|
16
|
+
userHasAccess: (...args: Parameters<typeof userHasAccess>) => mockUserHasAccess(...args),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
// Helper to create a mock user
|
|
20
|
+
function createMockUser(privileges: string[] = [], roles: string[] = []): LoggedInUser {
|
|
21
|
+
return {
|
|
22
|
+
uuid: 'user-uuid',
|
|
23
|
+
display: 'Test User',
|
|
24
|
+
username: 'testuser',
|
|
25
|
+
systemId: 'testuser',
|
|
26
|
+
userProperties: {},
|
|
27
|
+
person: {
|
|
28
|
+
uuid: 'person-uuid',
|
|
29
|
+
display: 'Test User',
|
|
30
|
+
},
|
|
31
|
+
privileges: privileges.map((priv) => ({
|
|
32
|
+
uuid: `priv-${priv}`,
|
|
33
|
+
display: priv,
|
|
34
|
+
})) as Privilege[],
|
|
35
|
+
roles: roles.map((role) => ({
|
|
36
|
+
uuid: `role-${role}`,
|
|
37
|
+
display: role,
|
|
38
|
+
})) as Role[],
|
|
39
|
+
retired: false,
|
|
40
|
+
locale: 'en',
|
|
41
|
+
allowedLocales: ['en'],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('UserHasAccess', () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
vi.clearAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(() => {
|
|
51
|
+
vi.clearAllMocks();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('when user has required privilege', () => {
|
|
55
|
+
it('should render children for single privilege', () => {
|
|
56
|
+
const user = createMockUser(['Edit Patients']);
|
|
57
|
+
|
|
58
|
+
mockGetCurrentUser.mockReturnValue(new Observable((subscriber) => subscriber.next(user)));
|
|
59
|
+
mockUserHasAccess.mockReturnValue(true);
|
|
60
|
+
|
|
61
|
+
render(
|
|
62
|
+
<UserHasAccess privilege="Edit Patients">
|
|
63
|
+
<div>Protected Content</div>
|
|
64
|
+
</UserHasAccess>,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
|
68
|
+
expect(mockUserHasAccess).toHaveBeenCalledWith('Edit Patients', user);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should render children for multiple privileges', () => {
|
|
72
|
+
const user = createMockUser(['Edit Patients', 'Delete Patients']);
|
|
73
|
+
|
|
74
|
+
mockGetCurrentUser.mockReturnValue(new Observable((subscriber) => subscriber.next(user)));
|
|
75
|
+
mockUserHasAccess.mockReturnValue(true);
|
|
76
|
+
|
|
77
|
+
render(
|
|
78
|
+
<UserHasAccess privilege={['Edit Patients', 'Delete Patients']}>
|
|
79
|
+
<div>Protected Content</div>
|
|
80
|
+
</UserHasAccess>,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
|
84
|
+
expect(mockUserHasAccess).toHaveBeenCalledWith(['Edit Patients', 'Delete Patients'], user);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should render multiple children', () => {
|
|
88
|
+
const user = createMockUser(['Edit Patients']);
|
|
89
|
+
|
|
90
|
+
mockGetCurrentUser.mockReturnValue(new Observable((subscriber) => subscriber.next(user)));
|
|
91
|
+
mockUserHasAccess.mockReturnValue(true);
|
|
92
|
+
|
|
93
|
+
render(
|
|
94
|
+
<UserHasAccess privilege="Edit Patients">
|
|
95
|
+
<div>First Child</div>
|
|
96
|
+
<div>Second Child</div>
|
|
97
|
+
</UserHasAccess>,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(screen.getByText('First Child')).toBeInTheDocument();
|
|
101
|
+
expect(screen.getByText('Second Child')).toBeInTheDocument();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('when user does not have required privilege', () => {
|
|
106
|
+
it('should render nothing when no fallback provided', () => {
|
|
107
|
+
const user = createMockUser(['View Patients']);
|
|
108
|
+
|
|
109
|
+
mockGetCurrentUser.mockReturnValue(new Observable((subscriber) => subscriber.next(user)));
|
|
110
|
+
mockUserHasAccess.mockReturnValue(false);
|
|
111
|
+
|
|
112
|
+
const { container } = render(
|
|
113
|
+
<UserHasAccess privilege="Edit Patients">
|
|
114
|
+
<div>Protected Content</div>
|
|
115
|
+
</UserHasAccess>,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
|
119
|
+
// eslint-disable-next-line jest-dom/prefer-empty, testing-library/no-node-access
|
|
120
|
+
expect(container.firstChild).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should render fallback when provided', () => {
|
|
124
|
+
const user = createMockUser(['View Patients']);
|
|
125
|
+
|
|
126
|
+
mockGetCurrentUser.mockReturnValue(new Observable((subscriber) => subscriber.next(user)));
|
|
127
|
+
mockUserHasAccess.mockReturnValue(false);
|
|
128
|
+
|
|
129
|
+
render(
|
|
130
|
+
<UserHasAccess privilege="Edit Patients" fallback={<div>Access Denied</div>}>
|
|
131
|
+
<div>Protected Content</div>
|
|
132
|
+
</UserHasAccess>,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
|
136
|
+
expect(screen.getByText('Access Denied')).toBeInTheDocument();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should render fallback for missing privilege in array', () => {
|
|
140
|
+
const user = createMockUser(['Edit Patients']); // Has one but not both
|
|
141
|
+
|
|
142
|
+
mockGetCurrentUser.mockReturnValue(new Observable((subscriber) => subscriber.next(user)));
|
|
143
|
+
mockUserHasAccess.mockReturnValue(false);
|
|
144
|
+
|
|
145
|
+
render(
|
|
146
|
+
<UserHasAccess privilege={['Edit Patients', 'Delete Patients']} fallback={<div>Need all privileges</div>}>
|
|
147
|
+
<div>Protected Content</div>
|
|
148
|
+
</UserHasAccess>,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
|
152
|
+
expect(screen.getByText('Need all privileges')).toBeInTheDocument();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('when user is not logged in', () => {
|
|
157
|
+
it('should render nothing when no fallback provided', () => {
|
|
158
|
+
mockGetCurrentUser.mockReturnValue(new Observable((subscriber) => subscriber.next(null)));
|
|
159
|
+
mockUserHasAccess.mockReturnValue(false);
|
|
160
|
+
|
|
161
|
+
const { container } = render(
|
|
162
|
+
<UserHasAccess privilege="Edit Patients">
|
|
163
|
+
<div>Protected Content</div>
|
|
164
|
+
</UserHasAccess>,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
|
168
|
+
// eslint-disable-next-line jest-dom/prefer-empty, testing-library/no-node-access
|
|
169
|
+
expect(container.firstChild).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should render fallback when provided', () => {
|
|
173
|
+
mockGetCurrentUser.mockReturnValue(new Observable((subscriber) => subscriber.next(null)));
|
|
174
|
+
mockUserHasAccess.mockReturnValue(false);
|
|
175
|
+
|
|
176
|
+
render(
|
|
177
|
+
<UserHasAccess privilege="Edit Patients" fallback={<div>Please log in</div>}>
|
|
178
|
+
<div>Protected Content</div>
|
|
179
|
+
</UserHasAccess>,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
|
183
|
+
expect(screen.getByText('Please log in')).toBeInTheDocument();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('observable subscription management', () => {
|
|
188
|
+
it('should subscribe to getCurrentUser on mount', () => {
|
|
189
|
+
const user = createMockUser(['Edit Patients']);
|
|
190
|
+
const subscribeMock = vi.fn();
|
|
191
|
+
|
|
192
|
+
mockGetCurrentUser.mockReturnValue(
|
|
193
|
+
new Observable((subscriber) => {
|
|
194
|
+
subscribeMock();
|
|
195
|
+
subscriber.next(user);
|
|
196
|
+
return () => {};
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
mockUserHasAccess.mockReturnValue(true);
|
|
200
|
+
|
|
201
|
+
render(
|
|
202
|
+
<UserHasAccess privilege="Edit Patients">
|
|
203
|
+
<div>Protected Content</div>
|
|
204
|
+
</UserHasAccess>,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
expect(mockGetCurrentUser).toHaveBeenCalledWith({ includeAuthStatus: false });
|
|
208
|
+
expect(subscribeMock).toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should unsubscribe from getCurrentUser on unmount', () => {
|
|
212
|
+
const user = createMockUser(['Edit Patients']);
|
|
213
|
+
const unsubscribeMock = vi.fn();
|
|
214
|
+
|
|
215
|
+
mockGetCurrentUser.mockReturnValue(
|
|
216
|
+
new Observable((subscriber) => {
|
|
217
|
+
subscriber.next(user);
|
|
218
|
+
return unsubscribeMock;
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
mockUserHasAccess.mockReturnValue(true);
|
|
222
|
+
|
|
223
|
+
const { unmount } = render(
|
|
224
|
+
<UserHasAccess privilege="Edit Patients">
|
|
225
|
+
<div>Protected Content</div>
|
|
226
|
+
</UserHasAccess>,
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
unmount();
|
|
230
|
+
|
|
231
|
+
expect(unsubscribeMock).toHaveBeenCalled();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('user updates', () => {
|
|
236
|
+
it('should update when user changes', async () => {
|
|
237
|
+
const user1 = createMockUser(['View Patients']);
|
|
238
|
+
const user2 = createMockUser(['Edit Patients']);
|
|
239
|
+
|
|
240
|
+
let subscriber: Subscriber<unknown> | undefined = undefined;
|
|
241
|
+
mockGetCurrentUser.mockReturnValue(
|
|
242
|
+
new Observable((sub) => {
|
|
243
|
+
subscriber = sub;
|
|
244
|
+
sub.next(user1);
|
|
245
|
+
return () => {};
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// Initially user doesn't have access
|
|
250
|
+
mockUserHasAccess.mockReturnValue(false);
|
|
251
|
+
|
|
252
|
+
const { rerender } = render(
|
|
253
|
+
<UserHasAccess privilege="Edit Patients" fallback={<div>No Access</div>}>
|
|
254
|
+
<div>Protected Content</div>
|
|
255
|
+
</UserHasAccess>,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
|
259
|
+
expect(screen.getByText('No Access')).toBeInTheDocument();
|
|
260
|
+
|
|
261
|
+
// User gains access
|
|
262
|
+
mockUserHasAccess.mockReturnValue(true);
|
|
263
|
+
(subscriber as unknown as Subscriber<unknown>)?.next(user2);
|
|
264
|
+
|
|
265
|
+
await waitFor(() => {
|
|
266
|
+
expect(screen.queryByText('No Access')).not.toBeInTheDocument();
|
|
267
|
+
});
|
|
268
|
+
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('edge cases', () => {
|
|
273
|
+
it('should handle empty children gracefully', () => {
|
|
274
|
+
const user = createMockUser(['Edit Patients']);
|
|
275
|
+
|
|
276
|
+
mockGetCurrentUser.mockReturnValue(new Observable((subscriber) => subscriber.next(user)));
|
|
277
|
+
mockUserHasAccess.mockReturnValue(true);
|
|
278
|
+
|
|
279
|
+
const { container } = render(<UserHasAccess privilege="Edit Patients" />);
|
|
280
|
+
|
|
281
|
+
// Should render empty fragment
|
|
282
|
+
// eslint-disable-next-line jest-dom/prefer-empty, testing-library/no-node-access
|
|
283
|
+
expect(container.firstChild).toBeNull();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should handle complex fallback component', () => {
|
|
287
|
+
const user = createMockUser(['View Patients']);
|
|
288
|
+
|
|
289
|
+
mockGetCurrentUser.mockReturnValue(new Observable((subscriber) => subscriber.next(user)));
|
|
290
|
+
mockUserHasAccess.mockReturnValue(false);
|
|
291
|
+
|
|
292
|
+
render(
|
|
293
|
+
<UserHasAccess
|
|
294
|
+
privilege="Edit Patients"
|
|
295
|
+
fallback={
|
|
296
|
+
<div>
|
|
297
|
+
<h1>Access Denied</h1>
|
|
298
|
+
<p>Contact administrator</p>
|
|
299
|
+
</div>
|
|
300
|
+
}
|
|
301
|
+
>
|
|
302
|
+
<div>Protected Content</div>
|
|
303
|
+
</UserHasAccess>,
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
expect(screen.getByText('Access Denied')).toBeInTheDocument();
|
|
307
|
+
expect(screen.getByText('Contact administrator')).toBeInTheDocument();
|
|
308
|
+
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { act, renderHook } from '@testing-library/react';
|
|
3
|
+
import { usePatient } from './usePatient';
|
|
4
|
+
|
|
5
|
+
// Mock SWR
|
|
6
|
+
const mockUseSWR = vi.fn();
|
|
7
|
+
vi.mock('swr', () => ({
|
|
8
|
+
default: (...args: any[]) => mockUseSWR(...args),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
// Mock fetchCurrentPatient
|
|
12
|
+
const mockFetchCurrentPatient = vi.fn();
|
|
13
|
+
vi.mock('@openmrs/esm-emr-api', () => ({
|
|
14
|
+
fetchCurrentPatient: (...args: any[]) => mockFetchCurrentPatient(...args),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// Helper to create a mock patient
|
|
18
|
+
function createMockPatient(id: string): fhir.Patient {
|
|
19
|
+
return {
|
|
20
|
+
resourceType: 'Patient',
|
|
21
|
+
id,
|
|
22
|
+
name: [{ given: ['John'], family: 'Doe' }],
|
|
23
|
+
gender: 'male',
|
|
24
|
+
birthDate: '1990-01-01',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('usePatient', () => {
|
|
29
|
+
let originalLocation: Location;
|
|
30
|
+
let mockAddEventListener: ReturnType<typeof vi.fn>;
|
|
31
|
+
let mockRemoveEventListener: ReturnType<typeof vi.fn>;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
// Store original location
|
|
35
|
+
originalLocation = window.location;
|
|
36
|
+
|
|
37
|
+
// Mock window.addEventListener and removeEventListener
|
|
38
|
+
mockAddEventListener = vi.fn();
|
|
39
|
+
mockRemoveEventListener = vi.fn();
|
|
40
|
+
window.addEventListener = mockAddEventListener;
|
|
41
|
+
window.removeEventListener = mockRemoveEventListener;
|
|
42
|
+
|
|
43
|
+
// Reset mocks
|
|
44
|
+
vi.clearAllMocks();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
// Restore original location
|
|
49
|
+
vi.unstubAllGlobals();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function mockLocation(pathname: string) {
|
|
53
|
+
vi.stubGlobal('location', { ...originalLocation, pathname });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('with patientUuid parameter', () => {
|
|
57
|
+
it('should fetch patient with provided UUID', () => {
|
|
58
|
+
const patientUuid = 'test-patient-123';
|
|
59
|
+
const mockPatient = createMockPatient(patientUuid);
|
|
60
|
+
|
|
61
|
+
mockUseSWR.mockReturnValue({
|
|
62
|
+
data: mockPatient,
|
|
63
|
+
error: null,
|
|
64
|
+
isValidating: false,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const { result } = renderHook(() => usePatient(patientUuid));
|
|
68
|
+
|
|
69
|
+
expect(mockUseSWR).toHaveBeenCalledWith(['patient', patientUuid], expect.any(Function));
|
|
70
|
+
expect(result.current.patient).toEqual(mockPatient);
|
|
71
|
+
expect(result.current.patientUuid).toBe(patientUuid);
|
|
72
|
+
expect(result.current.isLoading).toBe(false);
|
|
73
|
+
expect(result.current.error).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should show loading state while fetching', () => {
|
|
77
|
+
const patientUuid = 'loading-patient-123';
|
|
78
|
+
|
|
79
|
+
mockUseSWR.mockReturnValue({
|
|
80
|
+
data: undefined,
|
|
81
|
+
error: null,
|
|
82
|
+
isValidating: true,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const { result } = renderHook(() => usePatient(patientUuid));
|
|
86
|
+
|
|
87
|
+
expect(result.current.isLoading).toBe(true);
|
|
88
|
+
expect(result.current.patient).toBeUndefined();
|
|
89
|
+
expect(result.current.error).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should handle error state', () => {
|
|
93
|
+
const patientUuid = 'error-patient-123';
|
|
94
|
+
const mockError = new Error('Failed to fetch patient');
|
|
95
|
+
|
|
96
|
+
mockUseSWR.mockReturnValue({
|
|
97
|
+
data: undefined,
|
|
98
|
+
error: mockError,
|
|
99
|
+
isValidating: false,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const { result } = renderHook(() => usePatient(patientUuid));
|
|
103
|
+
|
|
104
|
+
expect(result.current.isLoading).toBe(false);
|
|
105
|
+
expect(result.current.patient).toBeUndefined();
|
|
106
|
+
expect(result.current.error).toBe(mockError);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should not show loading when error is present', () => {
|
|
110
|
+
const patientUuid = 'error-loading-patient-123';
|
|
111
|
+
const mockError = new Error('Network error');
|
|
112
|
+
|
|
113
|
+
mockUseSWR.mockReturnValue({
|
|
114
|
+
data: undefined,
|
|
115
|
+
error: mockError,
|
|
116
|
+
isValidating: true,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const { result } = renderHook(() => usePatient(patientUuid));
|
|
120
|
+
|
|
121
|
+
expect(result.current.isLoading).toBe(false);
|
|
122
|
+
expect(result.current.error).toBe(mockError);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should not show loading when patient data exists', () => {
|
|
126
|
+
const patientUuid = 'cached-patient-123';
|
|
127
|
+
const mockPatient = createMockPatient(patientUuid);
|
|
128
|
+
|
|
129
|
+
mockUseSWR.mockReturnValue({
|
|
130
|
+
data: mockPatient,
|
|
131
|
+
error: null,
|
|
132
|
+
isValidating: true,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const { result } = renderHook(() => usePatient(patientUuid));
|
|
136
|
+
|
|
137
|
+
expect(result.current.isLoading).toBe(false);
|
|
138
|
+
expect(result.current.patient).toEqual(mockPatient);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('without patientUuid parameter', () => {
|
|
143
|
+
it('should extract patient UUID from URL', () => {
|
|
144
|
+
mockLocation('/patient/url-patient-456/chart');
|
|
145
|
+
|
|
146
|
+
mockUseSWR.mockReturnValue({
|
|
147
|
+
data: null,
|
|
148
|
+
error: null,
|
|
149
|
+
isValidating: false,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const { result } = renderHook(() => usePatient());
|
|
153
|
+
|
|
154
|
+
expect(result.current.patientUuid).toBe('url-patient-456');
|
|
155
|
+
expect(mockUseSWR).toHaveBeenCalledWith(['patient', 'url-patient-456'], expect.any(Function));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should handle URL without patient UUID', () => {
|
|
159
|
+
mockLocation('/home/dashboard');
|
|
160
|
+
|
|
161
|
+
mockUseSWR.mockReturnValue({
|
|
162
|
+
data: null,
|
|
163
|
+
error: null,
|
|
164
|
+
isValidating: false,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const { result } = renderHook(() => usePatient());
|
|
168
|
+
|
|
169
|
+
expect(result.current.patientUuid).toBeNull();
|
|
170
|
+
expect(mockUseSWR).toHaveBeenCalledWith(null, expect.any(Function));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should handle patient UUID with dashes', () => {
|
|
174
|
+
mockLocation('/patient/abc-123-def-456/encounters');
|
|
175
|
+
|
|
176
|
+
mockUseSWR.mockReturnValue({
|
|
177
|
+
data: null,
|
|
178
|
+
error: null,
|
|
179
|
+
isValidating: false,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const { result } = renderHook(() => usePatient());
|
|
183
|
+
|
|
184
|
+
expect(result.current.patientUuid).toBe('abc-123-def-456');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should handle patient URL at root level', () => {
|
|
188
|
+
mockLocation('/patient/root-patient-789');
|
|
189
|
+
|
|
190
|
+
mockUseSWR.mockReturnValue({
|
|
191
|
+
data: null,
|
|
192
|
+
error: null,
|
|
193
|
+
isValidating: false,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const { result } = renderHook(() => usePatient());
|
|
197
|
+
|
|
198
|
+
expect(result.current.patientUuid).toBe('root-patient-789');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should handle patient URL with trailing slash', () => {
|
|
202
|
+
mockLocation('/patient/trailing-patient-101/');
|
|
203
|
+
|
|
204
|
+
mockUseSWR.mockReturnValue({
|
|
205
|
+
data: null,
|
|
206
|
+
error: null,
|
|
207
|
+
isValidating: false,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const { result } = renderHook(() => usePatient());
|
|
211
|
+
|
|
212
|
+
expect(result.current.patientUuid).toBe('trailing-patient-101');
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('routing event handling', () => {
|
|
217
|
+
it('should register routing event listener on mount', () => {
|
|
218
|
+
mockLocation('/patient/initial-patient-111');
|
|
219
|
+
|
|
220
|
+
mockUseSWR.mockReturnValue({
|
|
221
|
+
data: null,
|
|
222
|
+
error: null,
|
|
223
|
+
isValidating: false,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
renderHook(() => usePatient());
|
|
227
|
+
|
|
228
|
+
expect(mockAddEventListener).toHaveBeenCalledWith('single-spa:routing-event', expect.any(Function));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should unregister routing event listener on unmount', () => {
|
|
232
|
+
mockLocation('/patient/unmount-patient-222');
|
|
233
|
+
|
|
234
|
+
mockUseSWR.mockReturnValue({
|
|
235
|
+
data: null,
|
|
236
|
+
error: null,
|
|
237
|
+
isValidating: false,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const { unmount } = renderHook(() => usePatient());
|
|
241
|
+
|
|
242
|
+
const eventHandler = mockAddEventListener.mock.calls[0][1];
|
|
243
|
+
|
|
244
|
+
unmount();
|
|
245
|
+
|
|
246
|
+
expect(mockRemoveEventListener).toHaveBeenCalledWith('single-spa:routing-event', eventHandler);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should update patient UUID when route changes', () => {
|
|
250
|
+
mockLocation('/patient/first-patient-333');
|
|
251
|
+
|
|
252
|
+
mockUseSWR.mockReturnValue({
|
|
253
|
+
data: null,
|
|
254
|
+
error: null,
|
|
255
|
+
isValidating: false,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const { result } = renderHook(() => usePatient());
|
|
259
|
+
|
|
260
|
+
expect(result.current.patientUuid).toBe('first-patient-333');
|
|
261
|
+
|
|
262
|
+
// Get the registered event handler
|
|
263
|
+
const eventHandler = mockAddEventListener.mock.calls[0][1];
|
|
264
|
+
|
|
265
|
+
// Simulate route change
|
|
266
|
+
mockLocation('/patient/second-patient-444');
|
|
267
|
+
act(() => {
|
|
268
|
+
eventHandler();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(result.current.patientUuid).toBe('second-patient-444');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should not update if route changes to same patient UUID', () => {
|
|
275
|
+
mockLocation('/patient/same-patient-555/chart');
|
|
276
|
+
|
|
277
|
+
mockUseSWR.mockReturnValue({
|
|
278
|
+
data: null,
|
|
279
|
+
error: null,
|
|
280
|
+
isValidating: false,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const { result } = renderHook(() => usePatient());
|
|
284
|
+
|
|
285
|
+
const initialPatientUuid = result.current.patientUuid;
|
|
286
|
+
const eventHandler = mockAddEventListener.mock.calls[0][1];
|
|
287
|
+
|
|
288
|
+
// Change URL but keep same patient UUID
|
|
289
|
+
mockLocation('/patient/same-patient-555/encounters');
|
|
290
|
+
act(() => {
|
|
291
|
+
eventHandler();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(result.current.patientUuid).toBe(initialPatientUuid);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should update when route changes from patient to no patient', () => {
|
|
298
|
+
mockLocation('/patient/has-patient-666');
|
|
299
|
+
|
|
300
|
+
mockUseSWR.mockReturnValue({
|
|
301
|
+
data: null,
|
|
302
|
+
error: null,
|
|
303
|
+
isValidating: false,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const { result } = renderHook(() => usePatient());
|
|
307
|
+
|
|
308
|
+
expect(result.current.patientUuid).toBe('has-patient-666');
|
|
309
|
+
|
|
310
|
+
const eventHandler = mockAddEventListener.mock.calls[0][1];
|
|
311
|
+
|
|
312
|
+
// Change to non-patient route
|
|
313
|
+
mockLocation('/home/dashboard');
|
|
314
|
+
act(() => {
|
|
315
|
+
eventHandler();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
expect(result.current.patientUuid).toBeNull();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should not listen to routing events when patientUuid is provided', () => {
|
|
322
|
+
mockLocation('/patient/ignored-patient-777');
|
|
323
|
+
|
|
324
|
+
mockUseSWR.mockReturnValue({
|
|
325
|
+
data: null,
|
|
326
|
+
error: null,
|
|
327
|
+
isValidating: false,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const { result } = renderHook(() => usePatient('explicit-patient-888'));
|
|
331
|
+
|
|
332
|
+
// Patient UUID should be from the parameter, not the URL
|
|
333
|
+
expect(result.current.patientUuid).toBe('explicit-patient-888');
|
|
334
|
+
|
|
335
|
+
// Event listener should still be registered for consistency
|
|
336
|
+
expect(mockAddEventListener).toHaveBeenCalled();
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe('SWR integration', () => {
|
|
341
|
+
it('should pass patient UUID to SWR key', () => {
|
|
342
|
+
const patientUuid = 'swr-patient-999';
|
|
343
|
+
|
|
344
|
+
mockUseSWR.mockReturnValue({
|
|
345
|
+
data: null,
|
|
346
|
+
error: null,
|
|
347
|
+
isValidating: false,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
renderHook(() => usePatient(patientUuid));
|
|
351
|
+
|
|
352
|
+
expect(mockUseSWR).toHaveBeenCalledWith(['patient', patientUuid], expect.any(Function));
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should pass null to SWR when no patient UUID', () => {
|
|
356
|
+
mockLocation('/home');
|
|
357
|
+
|
|
358
|
+
mockUseSWR.mockReturnValue({
|
|
359
|
+
data: null,
|
|
360
|
+
error: null,
|
|
361
|
+
isValidating: false,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
renderHook(() => usePatient());
|
|
365
|
+
|
|
366
|
+
expect(mockUseSWR).toHaveBeenCalledWith(null, expect.any(Function));
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('should call fetchCurrentPatient when SWR fetcher is invoked', async () => {
|
|
370
|
+
const patientUuid = 'fetch-patient-1010';
|
|
371
|
+
const mockPatient = createMockPatient(patientUuid);
|
|
372
|
+
|
|
373
|
+
mockFetchCurrentPatient.mockResolvedValue(mockPatient);
|
|
374
|
+
|
|
375
|
+
let swrFetcher: any;
|
|
376
|
+
mockUseSWR.mockImplementation((key, fetcher) => {
|
|
377
|
+
swrFetcher = fetcher;
|
|
378
|
+
return {
|
|
379
|
+
data: null,
|
|
380
|
+
error: null,
|
|
381
|
+
isValidating: true,
|
|
382
|
+
};
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
renderHook(() => usePatient(patientUuid));
|
|
386
|
+
|
|
387
|
+
// Call the SWR fetcher
|
|
388
|
+
const result = await swrFetcher();
|
|
389
|
+
|
|
390
|
+
expect(mockFetchCurrentPatient).toHaveBeenCalledWith(patientUuid, {});
|
|
391
|
+
expect(result).toEqual(mockPatient);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
describe('return value', () => {
|
|
396
|
+
it('should return all expected properties', () => {
|
|
397
|
+
const patientUuid = 'complete-patient-1111';
|
|
398
|
+
const mockPatient = createMockPatient(patientUuid);
|
|
399
|
+
|
|
400
|
+
mockUseSWR.mockReturnValue({
|
|
401
|
+
data: mockPatient,
|
|
402
|
+
error: null,
|
|
403
|
+
isValidating: false,
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const { result } = renderHook(() => usePatient(patientUuid));
|
|
407
|
+
|
|
408
|
+
expect(result.current).toHaveProperty('isLoading');
|
|
409
|
+
expect(result.current).toHaveProperty('patient');
|
|
410
|
+
expect(result.current).toHaveProperty('patientUuid');
|
|
411
|
+
expect(result.current).toHaveProperty('error');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('should memoize return value correctly', () => {
|
|
415
|
+
const patientUuid = 'memo-patient-1212';
|
|
416
|
+
|
|
417
|
+
mockUseSWR.mockReturnValue({
|
|
418
|
+
data: null,
|
|
419
|
+
error: null,
|
|
420
|
+
isValidating: false,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const { result, rerender } = renderHook(() => usePatient(patientUuid));
|
|
424
|
+
|
|
425
|
+
const firstResult = result.current;
|
|
426
|
+
rerender();
|
|
427
|
+
const secondResult = result.current;
|
|
428
|
+
|
|
429
|
+
// Should be the same object reference if values haven't changed
|
|
430
|
+
expect(firstResult).toBe(secondResult);
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
});
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type useSWR from 'swr';
|
|
3
|
+
import type { BareFetcher, Key } from 'swr';
|
|
4
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
5
|
+
import { useVisit } from './useVisit';
|
|
6
|
+
import type { Visit } from '@openmrs/esm-emr-api';
|
|
7
|
+
|
|
8
|
+
// Mock openmrsFetch
|
|
9
|
+
const mockOpenmrsFetch = vi.fn();
|
|
10
|
+
vi.mock('@openmrs/esm-api', () => ({
|
|
11
|
+
openmrsFetch: (...args: any[]) => mockOpenmrsFetch(...args),
|
|
12
|
+
restBaseUrl: '/ws/rest/v1',
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
// SWR mock state - will be inspected by mockUseSWR to return appropriate values
|
|
16
|
+
let activeVisitMockState: {
|
|
17
|
+
data?: { data: { results: Array<Visit> } };
|
|
18
|
+
error?: Error | null;
|
|
19
|
+
mutate: () => void;
|
|
20
|
+
isValidating: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let retroVisitMockState: {
|
|
24
|
+
data?: { data: Visit };
|
|
25
|
+
error?: Error | null;
|
|
26
|
+
mutate: () => void;
|
|
27
|
+
isValidating: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Mock SWR - inspects the key to determine which state to return
|
|
31
|
+
// Accepts both key and fetcher to match SWR's signature, but only uses key for logic
|
|
32
|
+
const mockUseSWR = vi.fn(<T = unknown>(key: Key, fetcher?: BareFetcher<T> | null) => {
|
|
33
|
+
if (key === null) {
|
|
34
|
+
return {
|
|
35
|
+
data: undefined,
|
|
36
|
+
error: null,
|
|
37
|
+
mutate: vi.fn(),
|
|
38
|
+
isValidating: false,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check if this is a retrospective visit call (has UUID in path like /visit/uuid-here)
|
|
43
|
+
if (typeof key === 'string' && key.includes('/visit/') && !key.includes('?patient=')) {
|
|
44
|
+
return retroVisitMockState;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Otherwise it's an active visit call
|
|
48
|
+
return activeVisitMockState;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
vi.mock('swr', () => ({
|
|
52
|
+
default: (...args: Parameters<typeof useSWR>) => mockUseSWR(args[0], args[1]),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
// Mock useVisitContextStore
|
|
56
|
+
const mockSetVisitContext = vi.fn();
|
|
57
|
+
const mockMutateVisit = vi.fn();
|
|
58
|
+
let mockVisitStoreState = {
|
|
59
|
+
patientUuid: null as string | null,
|
|
60
|
+
manuallySetVisitUuid: null as string | null,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
vi.mock('./useVisitContextStore', () => ({
|
|
64
|
+
useVisitContextStore: vi.fn((callback?: () => void) => {
|
|
65
|
+
return {
|
|
66
|
+
...mockVisitStoreState,
|
|
67
|
+
setVisitContext: mockSetVisitContext,
|
|
68
|
+
mutateVisit: mockMutateVisit,
|
|
69
|
+
};
|
|
70
|
+
}),
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
// Mock dayjs
|
|
74
|
+
vi.mock('dayjs', () => {
|
|
75
|
+
const mockDayjs: any = vi.fn(() => ({
|
|
76
|
+
isToday: vi.fn(() => false),
|
|
77
|
+
}));
|
|
78
|
+
mockDayjs.extend = vi.fn();
|
|
79
|
+
return {
|
|
80
|
+
default: mockDayjs,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Helper to create a mock visit
|
|
85
|
+
function createMockVisit(uuid: string, overrides: Partial<Visit> = {}): Visit {
|
|
86
|
+
return {
|
|
87
|
+
uuid,
|
|
88
|
+
display: `Visit ${uuid}`,
|
|
89
|
+
startDatetime: '2025-11-03T10:00:00.000Z',
|
|
90
|
+
stopDatetime: null,
|
|
91
|
+
visitType: {
|
|
92
|
+
uuid: 'visit-type-1',
|
|
93
|
+
display: 'Facility Visit',
|
|
94
|
+
},
|
|
95
|
+
patient: {
|
|
96
|
+
uuid: 'patient-123',
|
|
97
|
+
display: 'John Doe',
|
|
98
|
+
},
|
|
99
|
+
...overrides,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe('useVisit', () => {
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
vi.clearAllMocks();
|
|
106
|
+
|
|
107
|
+
// Reset mock states
|
|
108
|
+
mockVisitStoreState = {
|
|
109
|
+
patientUuid: null,
|
|
110
|
+
manuallySetVisitUuid: null,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
activeVisitMockState = {
|
|
114
|
+
data: undefined,
|
|
115
|
+
error: null,
|
|
116
|
+
mutate: vi.fn(),
|
|
117
|
+
isValidating: false,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
retroVisitMockState = {
|
|
121
|
+
data: undefined,
|
|
122
|
+
error: null,
|
|
123
|
+
mutate: vi.fn(),
|
|
124
|
+
isValidating: false,
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
vi.clearAllMocks();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('without patientUuid', () => {
|
|
133
|
+
it('should not fetch visits when patientUuid is empty string', () => {
|
|
134
|
+
const { result } = renderHook(() => useVisit(''));
|
|
135
|
+
|
|
136
|
+
expect(mockUseSWR).toHaveBeenCalledWith(null, expect.any(Function));
|
|
137
|
+
expect(result.current.activeVisit).toBeNull();
|
|
138
|
+
expect(result.current.currentVisit).toBeNull();
|
|
139
|
+
// isLoading is true because there's no data when patientUuid is empty
|
|
140
|
+
expect(result.current.isLoading).toBe(true);
|
|
141
|
+
expect(result.current.error).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('with patientUuid - active visit', () => {
|
|
146
|
+
it('should use custom representation when provided', () => {
|
|
147
|
+
const customRep = 'custom:(uuid,display)';
|
|
148
|
+
const activeVisit = createMockVisit('visit-1', { stopDatetime: null });
|
|
149
|
+
|
|
150
|
+
activeVisitMockState.data = { data: { results: [activeVisit] } };
|
|
151
|
+
|
|
152
|
+
const { result } = renderHook(() => useVisit('patient-123', customRep));
|
|
153
|
+
|
|
154
|
+
// Verify the custom representation is used in the SWR call
|
|
155
|
+
expect(mockUseSWR).toHaveBeenCalledWith(expect.stringContaining(`v=${customRep}`), expect.any(Function));
|
|
156
|
+
// Verify the hook still returns the correct data
|
|
157
|
+
expect(result.current.activeVisit).toEqual(activeVisit);
|
|
158
|
+
expect(result.current.isLoading).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should return active visit when found', () => {
|
|
162
|
+
const activeVisit = createMockVisit('visit-1', { stopDatetime: null });
|
|
163
|
+
const endedVisit = createMockVisit('visit-2', { stopDatetime: '2025-11-02T18:00:00.000Z' });
|
|
164
|
+
|
|
165
|
+
activeVisitMockState.data = { data: { results: [activeVisit, endedVisit] } };
|
|
166
|
+
|
|
167
|
+
const { result } = renderHook(() => useVisit('patient-123'));
|
|
168
|
+
|
|
169
|
+
expect(result.current.activeVisit).toEqual(activeVisit);
|
|
170
|
+
expect(result.current.currentVisit).toBeNull();
|
|
171
|
+
expect(result.current.currentVisitIsRetrospective).toBe(false);
|
|
172
|
+
expect(result.current.isLoading).toBe(false);
|
|
173
|
+
expect(result.current.error).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should return null when no active visit exists', () => {
|
|
177
|
+
const endedVisit = createMockVisit('visit-1', { stopDatetime: '2025-11-02T18:00:00.000Z' });
|
|
178
|
+
|
|
179
|
+
activeVisitMockState.data = { data: { results: [endedVisit] } };
|
|
180
|
+
|
|
181
|
+
const { result } = renderHook(() => useVisit('patient-123'));
|
|
182
|
+
|
|
183
|
+
expect(result.current.activeVisit).toBeNull();
|
|
184
|
+
expect(result.current.currentVisit).toBeNull();
|
|
185
|
+
expect(result.current.isLoading).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should return null when visits array is empty', () => {
|
|
189
|
+
activeVisitMockState.data = { data: { results: [] } };
|
|
190
|
+
|
|
191
|
+
const { result } = renderHook(() => useVisit('patient-123'));
|
|
192
|
+
|
|
193
|
+
expect(result.current.activeVisit).toBeNull();
|
|
194
|
+
expect(result.current.currentVisit).toBeNull();
|
|
195
|
+
expect(result.current.isLoading).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('with retrospective visit', () => {
|
|
200
|
+
it('should not fetch retrospective visit for different patient', () => {
|
|
201
|
+
// Visit store has a retrospective visit set for a different patient
|
|
202
|
+
mockVisitStoreState.patientUuid = 'other-patient';
|
|
203
|
+
mockVisitStoreState.manuallySetVisitUuid = 'retro-visit-456';
|
|
204
|
+
|
|
205
|
+
activeVisitMockState.data = { data: { results: [] } };
|
|
206
|
+
|
|
207
|
+
const { result } = renderHook(() => useVisit('patient-123'));
|
|
208
|
+
|
|
209
|
+
// Should not fetch the retrospective visit (SWR called with null for retro)
|
|
210
|
+
expect(mockUseSWR).toHaveBeenCalledWith(null, expect.any(Function));
|
|
211
|
+
// Should not have a current visit
|
|
212
|
+
expect(result.current.currentVisit).toBeNull();
|
|
213
|
+
expect(result.current.currentVisitIsRetrospective).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should return retrospective visit as currentVisit', () => {
|
|
217
|
+
mockVisitStoreState.patientUuid = 'patient-123';
|
|
218
|
+
mockVisitStoreState.manuallySetVisitUuid = 'retro-visit-456';
|
|
219
|
+
|
|
220
|
+
const retroVisit = createMockVisit('retro-visit-456', {
|
|
221
|
+
stopDatetime: '2025-10-30T18:00:00.000Z',
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
activeVisitMockState.data = { data: { results: [] } };
|
|
225
|
+
retroVisitMockState.data = { data: retroVisit };
|
|
226
|
+
|
|
227
|
+
const { result } = renderHook(() => useVisit('patient-123'));
|
|
228
|
+
|
|
229
|
+
expect(result.current.activeVisit).toBeNull();
|
|
230
|
+
expect(result.current.currentVisit).toEqual(retroVisit);
|
|
231
|
+
expect(result.current.currentVisitIsRetrospective).toBe(true);
|
|
232
|
+
expect(result.current.isLoading).toBe(false);
|
|
233
|
+
expect(result.current.error).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should return null currentVisit when no retrospective visit set', () => {
|
|
237
|
+
mockVisitStoreState.patientUuid = 'patient-123';
|
|
238
|
+
mockVisitStoreState.manuallySetVisitUuid = null;
|
|
239
|
+
|
|
240
|
+
activeVisitMockState.data = { data: { results: [] } };
|
|
241
|
+
|
|
242
|
+
const { result } = renderHook(() => useVisit('patient-123'));
|
|
243
|
+
|
|
244
|
+
expect(result.current.currentVisit).toBeNull();
|
|
245
|
+
expect(result.current.currentVisitIsRetrospective).toBe(false);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('error handling', () => {
|
|
250
|
+
it('should return error from active visit fetch', () => {
|
|
251
|
+
const mockError = new Error('Failed to fetch active visits');
|
|
252
|
+
|
|
253
|
+
activeVisitMockState.data = { data: { results: [] } };
|
|
254
|
+
activeVisitMockState.error = mockError;
|
|
255
|
+
|
|
256
|
+
const { result } = renderHook(() => useVisit('patient-123'));
|
|
257
|
+
|
|
258
|
+
expect(result.current.error).toBe(mockError);
|
|
259
|
+
expect(result.current.activeVisit).toBeNull();
|
|
260
|
+
expect(result.current.isLoading).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should return error from retrospective visit fetch', () => {
|
|
264
|
+
mockVisitStoreState.patientUuid = 'patient-123';
|
|
265
|
+
mockVisitStoreState.manuallySetVisitUuid = 'retro-visit-456';
|
|
266
|
+
|
|
267
|
+
const mockError = new Error('Failed to fetch retrospective visit');
|
|
268
|
+
|
|
269
|
+
activeVisitMockState.data = { data: { results: [] } };
|
|
270
|
+
retroVisitMockState.data = { data: createMockVisit('retro-visit-456') };
|
|
271
|
+
retroVisitMockState.error = mockError;
|
|
272
|
+
|
|
273
|
+
const { result } = renderHook(() => useVisit('patient-123'));
|
|
274
|
+
|
|
275
|
+
expect(result.current.error).toBe(mockError);
|
|
276
|
+
expect(result.current.isLoading).toBe(false);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should prioritize active visit error over retrospective error', () => {
|
|
280
|
+
mockVisitStoreState.patientUuid = 'patient-123';
|
|
281
|
+
mockVisitStoreState.manuallySetVisitUuid = 'retro-visit-456';
|
|
282
|
+
|
|
283
|
+
const activeError = new Error('Active visit error');
|
|
284
|
+
const retroError = new Error('Retro visit error');
|
|
285
|
+
|
|
286
|
+
activeVisitMockState.error = activeError;
|
|
287
|
+
retroVisitMockState.error = retroError;
|
|
288
|
+
|
|
289
|
+
const { result } = renderHook(() => useVisit('patient-123'));
|
|
290
|
+
|
|
291
|
+
expect(result.current.error).toBe(activeError);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('loading and validation states', () => {
|
|
296
|
+
it('should show isValidating when active visit is loading', () => {
|
|
297
|
+
activeVisitMockState.isValidating = true;
|
|
298
|
+
|
|
299
|
+
const { result } = renderHook(() => useVisit('patient-123'));
|
|
300
|
+
|
|
301
|
+
expect(result.current.isValidating).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should show isValidating when retrospective visit is loading', () => {
|
|
305
|
+
mockVisitStoreState.patientUuid = 'patient-123';
|
|
306
|
+
mockVisitStoreState.manuallySetVisitUuid = 'retro-visit-456';
|
|
307
|
+
|
|
308
|
+
activeVisitMockState.data = { data: { results: [] } };
|
|
309
|
+
retroVisitMockState.isValidating = true;
|
|
310
|
+
|
|
311
|
+
const { result } = renderHook(() => useVisit('patient-123'));
|
|
312
|
+
|
|
313
|
+
expect(result.current.isValidating).toBe(true);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('should show isLoading when no active data and no error', () => {
|
|
317
|
+
const { result } = renderHook(() => useVisit('patient-123'));
|
|
318
|
+
|
|
319
|
+
expect(result.current.isLoading).toBe(true);
|
|
320
|
+
expect(result.current.activeVisit).toBeNull();
|
|
321
|
+
expect(result.current.error).toBeNull();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should not show isLoading when active data is loaded', () => {
|
|
325
|
+
activeVisitMockState.data = { data: { results: [] } };
|
|
326
|
+
|
|
327
|
+
const { result } = renderHook(() => useVisit('patient-123'));
|
|
328
|
+
|
|
329
|
+
expect(result.current.isLoading).toBe(false);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should not show isLoading when active error exists and no retrospective visit', () => {
|
|
333
|
+
// When not viewing a retrospective visit, activeError should stop loading
|
|
334
|
+
const mockError = new Error('Network error');
|
|
335
|
+
activeVisitMockState.error = mockError;
|
|
336
|
+
// No data needed - error alone should stop loading
|
|
337
|
+
|
|
338
|
+
const { result } = renderHook(() => useVisit('patient-123'));
|
|
339
|
+
|
|
340
|
+
expect(result.current.isLoading).toBe(false);
|
|
341
|
+
expect(result.current.error).toBe(mockError);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('should not show isLoading when retro error exists and retrospective visit is set', () => {
|
|
345
|
+
// When viewing a retrospective visit, retroError should stop loading
|
|
346
|
+
mockVisitStoreState.patientUuid = 'patient-123';
|
|
347
|
+
mockVisitStoreState.manuallySetVisitUuid = 'retro-visit-456';
|
|
348
|
+
|
|
349
|
+
const mockError = new Error('Failed to load retrospective visit');
|
|
350
|
+
activeVisitMockState.data = { data: { results: [] } };
|
|
351
|
+
retroVisitMockState.error = mockError;
|
|
352
|
+
// No retro data needed - error alone should stop loading
|
|
353
|
+
|
|
354
|
+
const { result } = renderHook(() => useVisit('patient-123'));
|
|
355
|
+
|
|
356
|
+
expect(result.current.isLoading).toBe(false);
|
|
357
|
+
expect(result.current.error).toBe(mockError);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('should show isLoading when retrospective visit expected but not loaded', () => {
|
|
361
|
+
mockVisitStoreState.patientUuid = 'patient-123';
|
|
362
|
+
mockVisitStoreState.manuallySetVisitUuid = 'retro-visit-456';
|
|
363
|
+
|
|
364
|
+
activeVisitMockState.data = { data: { results: [] } };
|
|
365
|
+
// retroVisitMockState.data is undefined
|
|
366
|
+
|
|
367
|
+
const { result } = renderHook(() => useVisit('patient-123'));
|
|
368
|
+
|
|
369
|
+
expect(result.current.isLoading).toBe(true);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe('mutate functionality', () => {
|
|
374
|
+
it('should call both mutate functions when mutate is invoked', () => {
|
|
375
|
+
// Set up retrospective visit so both SWR calls are made
|
|
376
|
+
mockVisitStoreState.patientUuid = 'patient-123';
|
|
377
|
+
mockVisitStoreState.manuallySetVisitUuid = 'retro-visit-456';
|
|
378
|
+
|
|
379
|
+
// Create stable mutate functions BEFORE setting up the hook
|
|
380
|
+
const activeMutate = vi.fn();
|
|
381
|
+
const retroMutate = vi.fn();
|
|
382
|
+
|
|
383
|
+
activeVisitMockState = {
|
|
384
|
+
data: { data: { results: [] } },
|
|
385
|
+
error: null,
|
|
386
|
+
mutate: activeMutate,
|
|
387
|
+
isValidating: false,
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
retroVisitMockState = {
|
|
391
|
+
data: { data: createMockVisit('retro-visit-456') },
|
|
392
|
+
error: null,
|
|
393
|
+
mutate: retroMutate,
|
|
394
|
+
isValidating: false,
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const { result } = renderHook(() => useVisit('patient-123'));
|
|
398
|
+
|
|
399
|
+
act(() => {
|
|
400
|
+
result.current.mutate();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
expect(activeMutate).toHaveBeenCalled();
|
|
404
|
+
expect(retroMutate).toHaveBeenCalled();
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
describe('visit context side effects', () => {
|
|
409
|
+
it('should set visit context when active visit exists and no manual visit set', async () => {
|
|
410
|
+
mockVisitStoreState.patientUuid = 'patient-123';
|
|
411
|
+
mockVisitStoreState.manuallySetVisitUuid = null;
|
|
412
|
+
|
|
413
|
+
const activeVisit = createMockVisit('visit-1', { stopDatetime: null });
|
|
414
|
+
activeVisitMockState.data = { data: { results: [activeVisit] } };
|
|
415
|
+
|
|
416
|
+
renderHook(() => useVisit('patient-123'));
|
|
417
|
+
|
|
418
|
+
await waitFor(() => {
|
|
419
|
+
expect(mockSetVisitContext).toHaveBeenCalledWith(activeVisit);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('should not set visit context when manual visit is already set', async () => {
|
|
424
|
+
mockVisitStoreState.patientUuid = 'patient-123';
|
|
425
|
+
mockVisitStoreState.manuallySetVisitUuid = 'manual-visit';
|
|
426
|
+
|
|
427
|
+
const activeVisit = createMockVisit('visit-1', { stopDatetime: null });
|
|
428
|
+
activeVisitMockState.data = { data: { results: [activeVisit] } };
|
|
429
|
+
|
|
430
|
+
renderHook(() => useVisit('patient-123'));
|
|
431
|
+
|
|
432
|
+
await waitFor(() => {
|
|
433
|
+
expect(mockSetVisitContext).not.toHaveBeenCalled();
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('should not set visit context for different patient', async () => {
|
|
438
|
+
mockVisitStoreState.patientUuid = 'other-patient';
|
|
439
|
+
mockVisitStoreState.manuallySetVisitUuid = null;
|
|
440
|
+
|
|
441
|
+
const activeVisit = createMockVisit('visit-1', { stopDatetime: null });
|
|
442
|
+
activeVisitMockState.data = { data: { results: [activeVisit] } };
|
|
443
|
+
|
|
444
|
+
renderHook(() => useVisit('patient-123'));
|
|
445
|
+
|
|
446
|
+
await waitFor(() => {
|
|
447
|
+
expect(mockSetVisitContext).not.toHaveBeenCalled();
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('should clear visit context when current visit gets ended', async () => {
|
|
452
|
+
mockVisitStoreState.patientUuid = 'patient-123';
|
|
453
|
+
mockVisitStoreState.manuallySetVisitUuid = 'retro-visit-456';
|
|
454
|
+
|
|
455
|
+
const activeVisitInitial = createMockVisit('retro-visit-456', { stopDatetime: null });
|
|
456
|
+
|
|
457
|
+
activeVisitMockState.data = { data: { results: [] } };
|
|
458
|
+
retroVisitMockState.data = { data: activeVisitInitial };
|
|
459
|
+
|
|
460
|
+
const { rerender } = renderHook(() => useVisit('patient-123'));
|
|
461
|
+
|
|
462
|
+
await waitFor(() => {
|
|
463
|
+
expect(mockSetVisitContext).not.toHaveBeenCalled();
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// Clear the mock to track new calls
|
|
467
|
+
mockSetVisitContext.mockClear();
|
|
468
|
+
|
|
469
|
+
// Update the visit to be ended
|
|
470
|
+
const activeVisitEnded = createMockVisit('retro-visit-456', {
|
|
471
|
+
stopDatetime: '2025-11-03T18:00:00.000Z',
|
|
472
|
+
});
|
|
473
|
+
retroVisitMockState.data = { data: activeVisitEnded };
|
|
474
|
+
|
|
475
|
+
rerender();
|
|
476
|
+
|
|
477
|
+
await waitFor(() => {
|
|
478
|
+
expect(mockSetVisitContext).toHaveBeenCalledWith(null);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
describe('combined active and retrospective visits', () => {
|
|
484
|
+
it('should return both active and current visit when retrospective is set', () => {
|
|
485
|
+
mockVisitStoreState.patientUuid = 'patient-123';
|
|
486
|
+
mockVisitStoreState.manuallySetVisitUuid = 'retro-visit-456';
|
|
487
|
+
|
|
488
|
+
const activeVisit = createMockVisit('visit-1', { stopDatetime: null });
|
|
489
|
+
const retroVisit = createMockVisit('retro-visit-456', {
|
|
490
|
+
stopDatetime: '2025-10-30T18:00:00.000Z',
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
activeVisitMockState.data = { data: { results: [activeVisit] } };
|
|
494
|
+
retroVisitMockState.data = { data: retroVisit };
|
|
495
|
+
|
|
496
|
+
const { result } = renderHook(() => useVisit('patient-123'));
|
|
497
|
+
|
|
498
|
+
expect(result.current.activeVisit).toEqual(activeVisit);
|
|
499
|
+
expect(result.current.currentVisit).toEqual(retroVisit);
|
|
500
|
+
expect(result.current.currentVisitIsRetrospective).toBe(true);
|
|
501
|
+
expect(result.current.isLoading).toBe(false);
|
|
502
|
+
expect(result.current.error).toBeNull();
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
});
|
package/src/useVisit.ts
CHANGED
|
@@ -103,6 +103,10 @@ export function useVisit(patientUuid: string, representation = defaultVisitCusto
|
|
|
103
103
|
|
|
104
104
|
useVisitContextStore(mutateVisit);
|
|
105
105
|
|
|
106
|
+
const waitingForData = Boolean(!activeData || (retrospectiveVisitUuid && !retroData));
|
|
107
|
+
const hasRelevantError = Boolean(retrospectiveVisitUuid ? retroError : activeError);
|
|
108
|
+
const isLoading = waitingForData && !hasRelevantError;
|
|
109
|
+
|
|
106
110
|
return {
|
|
107
111
|
error: activeError || retroError,
|
|
108
112
|
mutate: mutateVisit,
|
|
@@ -110,6 +114,6 @@ export function useVisit(patientUuid: string, representation = defaultVisitCusto
|
|
|
110
114
|
activeVisit,
|
|
111
115
|
currentVisit,
|
|
112
116
|
currentVisitIsRetrospective: Boolean(retrospectiveVisitUuid),
|
|
113
|
-
isLoading
|
|
117
|
+
isLoading,
|
|
114
118
|
};
|
|
115
119
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|