@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.
@@ -1,3 +1,3 @@
1
- [0] Successfully compiled: 52 files with swc (161.87ms)
1
+ [0] Successfully compiled: 52 files with swc (149.49ms)
2
2
  [0] swc --strip-leading-paths src -d dist exited with code 0
3
3
  [1] tsc --project tsconfig.build.json exited with code 0
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: Boolean((!activeData || retrospectiveVisitUuid && !retroData) && (!activeError || !retroError))
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.3511",
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.3511",
82
- "@openmrs/esm-config": "8.0.1-pre.3511",
83
- "@openmrs/esm-context": "8.0.1-pre.3511",
84
- "@openmrs/esm-emr-api": "8.0.1-pre.3511",
85
- "@openmrs/esm-error-handling": "8.0.1-pre.3511",
86
- "@openmrs/esm-extensions": "8.0.1-pre.3511",
87
- "@openmrs/esm-feature-flags": "8.0.1-pre.3511",
88
- "@openmrs/esm-globals": "8.0.1-pre.3511",
89
- "@openmrs/esm-navigation": "8.0.1-pre.3511",
90
- "@openmrs/esm-state": "8.0.1-pre.3511",
91
- "@openmrs/esm-utils": "8.0.1-pre.3511",
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": "^3.1.4"
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: Boolean((!activeData || (retrospectiveVisitUuid && !retroData)) && (!activeError || !retroError)),
117
+ isLoading,
114
118
  };
115
119
  }