@openmrs/esm-fast-data-entry-app 1.4.2-pre.702 → 1.4.2-pre.706

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.
@@ -0,0 +1,302 @@
1
+ import { navigate } from '@openmrs/esm-framework';
2
+ import { initialWorkflowState } from './GroupFormWorkflowContext';
3
+ import reducer, { fdeGroupWorkflowStorageName, fdeGroupWorkflowStorageVersion } from './GroupFormWorkflowReducer';
4
+
5
+ jest.mock('uuid', () => ({
6
+ v4: jest.fn(() => 'generated-session-uuid'),
7
+ }));
8
+
9
+ const mockNavigate = jest.mocked(navigate);
10
+
11
+ const buildState = (formStateOverrides = {}, rootStateOverrides = {}) => ({
12
+ ...initialWorkflowState,
13
+ activeFormUuid: 'group-form',
14
+ userUuid: 'user-1',
15
+ forms: {
16
+ 'group-form': {
17
+ workflowState: 'NEW_GROUP_SESSION',
18
+ groupUuid: null,
19
+ groupName: null,
20
+ groupMembers: [],
21
+ activePatientUuid: null,
22
+ activeEncounterUuid: null,
23
+ activeVisitUuid: null,
24
+ activeSessionUuid: null,
25
+ patientUuids: [],
26
+ encounters: {},
27
+ visits: {},
28
+ ...formStateOverrides,
29
+ },
30
+ },
31
+ ...rootStateOverrides,
32
+ });
33
+
34
+ describe('GroupFormWorkflowReducer', () => {
35
+ beforeEach(() => {
36
+ localStorage.clear();
37
+ });
38
+
39
+ it('initializes a fresh group workflow state when there is no saved session', () => {
40
+ const state = reducer(initialWorkflowState, {
41
+ type: 'INITIALIZE_WORKFLOW_STATE',
42
+ activeFormUuid: 'group-form',
43
+ userUuid: 'user-1',
44
+ });
45
+
46
+ expect(state.activeFormUuid).toBe('group-form');
47
+ expect(state.forms['group-form']).toMatchObject({
48
+ workflowState: 'NEW_GROUP_SESSION',
49
+ groupUuid: null,
50
+ patientUuids: [],
51
+ encounters: {},
52
+ visits: {},
53
+ });
54
+
55
+ expect(JSON.parse(localStorage.getItem(`${fdeGroupWorkflowStorageName}:user-1`))).toMatchObject({
56
+ _storageVersion: fdeGroupWorkflowStorageVersion,
57
+ activeFormUuid: 'group-form',
58
+ });
59
+ });
60
+
61
+ it('restores a saved workflow and derives the current patient and visit state', () => {
62
+ localStorage.setItem(
63
+ `${fdeGroupWorkflowStorageName}:user-1`,
64
+ JSON.stringify({
65
+ _storageVersion: fdeGroupWorkflowStorageVersion,
66
+ activeFormUuid: 'group-form',
67
+ userUuid: 'user-1',
68
+ forms: {
69
+ 'group-form': {
70
+ workflowState: 'EDIT_FORM',
71
+ patientUuids: ['patient-a', 'patient-b'],
72
+ encounters: {
73
+ 'patient-a': 'encounter-a',
74
+ },
75
+ visits: {
76
+ 'patient-a': 'visit-a',
77
+ },
78
+ },
79
+ },
80
+ }),
81
+ );
82
+
83
+ const state = reducer(initialWorkflowState, {
84
+ type: 'INITIALIZE_WORKFLOW_STATE',
85
+ activeFormUuid: 'group-form',
86
+ userUuid: 'user-1',
87
+ });
88
+
89
+ expect(state.forms['group-form']).toMatchObject({
90
+ workflowState: 'EDIT_FORM',
91
+ activePatientUuid: 'patient-a',
92
+ activeEncounterUuid: 'encounter-a',
93
+ activeVisitUuid: 'visit-a',
94
+ activeSessionUuid: 'generated-session-uuid',
95
+ });
96
+ });
97
+
98
+ it('stores the selected group and clears the active patient state', () => {
99
+ const state = buildState({
100
+ activePatientUuid: 'patient-z',
101
+ activeEncounterUuid: 'encounter-z',
102
+ activeVisitUuid: 'visit-z',
103
+ activeSessionUuid: 'session-z',
104
+ });
105
+
106
+ const nextState = reducer(state, {
107
+ type: 'SET_GROUP',
108
+ group: {
109
+ uuid: 'cohort-1',
110
+ name: 'Nutrition Cohort',
111
+ cohortMembers: [{ patient: { uuid: 'patient-a' } }, { patient: { uuid: 'patient-b' } }],
112
+ },
113
+ });
114
+
115
+ expect(nextState.forms['group-form']).toMatchObject({
116
+ groupUuid: 'cohort-1',
117
+ groupName: 'Nutrition Cohort',
118
+ groupMembers: ['patient-a', 'patient-b'],
119
+ patientUuids: ['patient-a', 'patient-b'],
120
+ activePatientUuid: null,
121
+ activeEncounterUuid: null,
122
+ activeVisitUuid: null,
123
+ activeSessionUuid: null,
124
+ });
125
+ });
126
+
127
+ it('sets session metadata and moves to the first patient in edit mode', () => {
128
+ const state = buildState({
129
+ patientUuids: ['patient-a', 'patient-b'],
130
+ encounters: {
131
+ 'patient-a': 'encounter-a',
132
+ },
133
+ visits: {
134
+ 'patient-a': 'visit-a',
135
+ },
136
+ });
137
+
138
+ const nextState = reducer(state, {
139
+ type: 'SET_SESSION_META',
140
+ meta: {
141
+ sessionName: 'April Session',
142
+ practitionerName: 'Alice',
143
+ sessionDate: '2026-04-15',
144
+ sessionNotes: 'Notes',
145
+ },
146
+ });
147
+
148
+ expect(nextState.forms['group-form']).toMatchObject({
149
+ sessionMeta: {
150
+ sessionName: 'April Session',
151
+ practitionerName: 'Alice',
152
+ sessionDate: '2026-04-15',
153
+ sessionNotes: 'Notes',
154
+ },
155
+ activePatientUuid: 'patient-a',
156
+ activeEncounterUuid: 'encounter-a',
157
+ activeVisitUuid: 'visit-a',
158
+ activeSessionUuid: 'generated-session-uuid',
159
+ workflowState: 'EDIT_FORM',
160
+ });
161
+ });
162
+
163
+ it('adds and removes patient UUIDs without duplicating entries', () => {
164
+ const state = buildState({
165
+ patientUuids: ['patient-a'],
166
+ });
167
+
168
+ const unchangedState = reducer(state, {
169
+ type: 'ADD_PATIENT_UUID',
170
+ patientUuid: 'patient-a',
171
+ });
172
+ expect(unchangedState).toBe(state);
173
+
174
+ const addedState = reducer(state, {
175
+ type: 'ADD_PATIENT_UUID',
176
+ patientUuid: 'patient-b',
177
+ });
178
+ expect(addedState.forms['group-form'].patientUuids).toEqual(['patient-a', 'patient-b']);
179
+
180
+ const removedState = reducer(addedState, {
181
+ type: 'REMOVE_PATIENT_UUID',
182
+ patientUuid: 'patient-a',
183
+ });
184
+ expect(removedState.forms['group-form'].patientUuids).toEqual(['patient-b']);
185
+ });
186
+
187
+ it('dispatches validate events and stores the visit UUID for the active patient', () => {
188
+ const dispatchEventSpy = jest.spyOn(window, 'dispatchEvent');
189
+ const state = buildState({
190
+ workflowState: 'EDIT_FORM',
191
+ activePatientUuid: 'patient-a',
192
+ });
193
+
194
+ const validatingState = reducer(state, {
195
+ type: 'VALIDATE_FOR_NEXT',
196
+ });
197
+
198
+ expect(validatingState.forms['group-form'].workflowState).toBe('VALIDATE_FOR_NEXT');
199
+ const event = dispatchEventSpy.mock.calls[0][0] as CustomEvent;
200
+ expect(event.detail).toEqual({
201
+ formUuid: 'group-form',
202
+ patientUuid: 'patient-a',
203
+ action: 'validateForm',
204
+ });
205
+
206
+ const visitState = reducer(state, {
207
+ type: 'UPDATE_VISIT_UUID',
208
+ visitUuid: 'visit-a',
209
+ });
210
+
211
+ expect(visitState.forms['group-form']).toMatchObject({
212
+ activeVisitUuid: 'visit-a',
213
+ visits: {
214
+ 'patient-a': 'visit-a',
215
+ },
216
+ });
217
+ });
218
+
219
+ it('submits for next and advances to the requested patient after saving', () => {
220
+ const dispatchEventSpy = jest.spyOn(window, 'dispatchEvent');
221
+ const state = buildState(
222
+ {
223
+ workflowState: 'EDIT_FORM',
224
+ activePatientUuid: 'patient-a',
225
+ patientUuids: ['patient-a', 'patient-b', 'patient-c'],
226
+ visits: {
227
+ 'patient-c': 'visit-c',
228
+ },
229
+ },
230
+ {
231
+ nextPatientUuid: null,
232
+ },
233
+ );
234
+
235
+ const submittingState = reducer(state, {
236
+ type: 'SUBMIT_FOR_NEXT',
237
+ nextPatientUuid: 'patient-c',
238
+ });
239
+
240
+ expect(submittingState.forms['group-form'].workflowState).toBe('SUBMIT_FOR_NEXT');
241
+ expect(submittingState.nextPatientUuid).toBe('patient-c');
242
+ const submitEvent = dispatchEventSpy.mock.calls[0][0] as CustomEvent;
243
+ expect(submitEvent.detail).toEqual({
244
+ formUuid: 'group-form',
245
+ patientUuid: 'patient-a',
246
+ action: 'onSubmit',
247
+ });
248
+
249
+ const savedState = reducer(submittingState, {
250
+ type: 'SAVE_ENCOUNTER',
251
+ encounterUuid: 'encounter-a',
252
+ });
253
+
254
+ expect(savedState.forms['group-form']).toMatchObject({
255
+ workflowState: 'EDIT_FORM',
256
+ activePatientUuid: 'patient-c',
257
+ activeEncounterUuid: null,
258
+ activeVisitUuid: 'visit-c',
259
+ encounters: {
260
+ 'patient-a': 'encounter-a',
261
+ },
262
+ });
263
+ });
264
+
265
+ it('clears the active session pointers when going to review', () => {
266
+ const state = buildState({
267
+ workflowState: 'EDIT_FORM',
268
+ activePatientUuid: 'patient-a',
269
+ activeEncounterUuid: 'encounter-a',
270
+ activeVisitUuid: 'visit-a',
271
+ activeSessionUuid: 'session-a',
272
+ });
273
+
274
+ const nextState = reducer(state, {
275
+ type: 'GO_TO_REVIEW',
276
+ });
277
+
278
+ expect(nextState.forms['group-form']).toMatchObject({
279
+ workflowState: 'REVIEW',
280
+ activePatientUuid: null,
281
+ activeEncounterUuid: null,
282
+ activeVisitUuid: null,
283
+ activeSessionUuid: null,
284
+ });
285
+ });
286
+
287
+ it('destroys the active group session and navigates back to forms', () => {
288
+ const state = buildState({
289
+ workflowState: 'SUBMIT_FOR_COMPLETE',
290
+ activePatientUuid: 'patient-a',
291
+ });
292
+
293
+ const nextState = reducer(state, {
294
+ type: 'DESTROY_SESSION',
295
+ });
296
+
297
+ expect(nextState.activeFormUuid).toBeNull();
298
+ expect(nextState.forms).toEqual({});
299
+ expect(nextState.formDestroyed).toBe(true);
300
+ expect(mockNavigate).toHaveBeenCalledWith({ to: '${openmrsSpaBase}/forms' });
301
+ });
302
+ });
@@ -365,7 +365,7 @@ const reducer = (state, action) => {
365
365
  [state.activeFormUuid]: {
366
366
  ...state.forms[state.activeFormUuid],
367
367
  activeEncounterUuid: null,
368
- activVisitUuid: null,
368
+ activeVisitUuid: null,
369
369
  activePatientUuid: null,
370
370
  activeSessionUuid: null,
371
371
  workflowState: 'REVIEW',
@@ -1,3 +1,2 @@
1
1
  declare module '*.css';
2
2
  declare module '*.scss';
3
- declare type SideNavProps = object;
@@ -0,0 +1,190 @@
1
+ import React from 'react';
2
+ import { act, render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { getGlobalStore, useConfig, useSession, useStore } from '@openmrs/esm-framework';
5
+ import FormBootstrap from '../FormBootstrap';
6
+ import GroupFormWorkflowContext from '../context/GroupFormWorkflowContext';
7
+ import GroupSessionWorkspace from './GroupSessionWorkspace';
8
+
9
+ jest.mock('@openmrs/esm-framework', () => ({
10
+ getGlobalStore: jest.fn(),
11
+ useConfig: jest.fn(),
12
+ useSession: jest.fn(),
13
+ useStore: jest.fn(),
14
+ }));
15
+
16
+ jest.mock('uuid', () => ({
17
+ v4: jest.fn(() => 'generated-visit-uuid'),
18
+ }));
19
+
20
+ jest.mock('../FormBootstrap', () => ({
21
+ __esModule: true,
22
+ default: jest.fn(() => <div data-testid="form-bootstrap" />),
23
+ }));
24
+
25
+ jest.mock('../patient-card/PatientCard', () => ({
26
+ __esModule: true,
27
+ default: ({ patientUuid, editEncounter }) => (
28
+ <button data-testid={`patient-card-${patientUuid}`} onClick={() => editEncounter(patientUuid)}>
29
+ {patientUuid}
30
+ </button>
31
+ ),
32
+ }));
33
+
34
+ jest.mock('../CancelModal', () => ({
35
+ __esModule: true,
36
+ default: () => null,
37
+ }));
38
+
39
+ jest.mock('../CompleteModal', () => ({
40
+ __esModule: true,
41
+ default: () => null,
42
+ }));
43
+
44
+ const mockGetGlobalStore = jest.mocked(getGlobalStore);
45
+ const mockUseConfig = jest.mocked(useConfig);
46
+ const mockUseSession = jest.mocked(useSession);
47
+ const mockUseStore = jest.mocked(useStore);
48
+ const mockFormBootstrap = FormBootstrap as jest.Mock;
49
+
50
+ const renderWorkspace = (contextOverrides = {}) => {
51
+ const defaultContext = {
52
+ workflowState: 'EDIT_FORM',
53
+ patientUuids: ['patient-a', 'patient-b'],
54
+ activePatientUuid: 'patient-a',
55
+ activeEncounterUuid: null,
56
+ activeVisitUuid: null,
57
+ activeFormUuid: 'group-form',
58
+ activeGroupUuid: 'group-1',
59
+ activeGroupName: 'Nutrition Cohort',
60
+ activeSessionUuid: 'session-1',
61
+ activeSessionMeta: {
62
+ sessionName: 'April Session',
63
+ practitionerName: 'Alice',
64
+ sessionDate: '2026-04-15',
65
+ sessionNotes: 'Bring notebooks',
66
+ },
67
+ groupVisitTypeUuid: 'visit-type-1',
68
+ encounters: {},
69
+ saveEncounter: jest.fn(),
70
+ updateVisitUuid: jest.fn(),
71
+ submitForNext: jest.fn(),
72
+ };
73
+
74
+ return render(
75
+ <GroupFormWorkflowContext.Provider value={{ ...defaultContext, ...contextOverrides } as never}>
76
+ <GroupSessionWorkspace />
77
+ </GroupFormWorkflowContext.Provider>,
78
+ );
79
+ };
80
+
81
+ describe('GroupSessionWorkspace', () => {
82
+ beforeEach(() => {
83
+ mockGetGlobalStore.mockReturnValue('ampath-form-state' as never);
84
+ mockUseStore.mockReturnValue({
85
+ 'group-form': 'ready',
86
+ } as never);
87
+ mockUseSession.mockReturnValue({
88
+ sessionLocation: {
89
+ uuid: 'session-location',
90
+ display: 'General Hospital',
91
+ },
92
+ } as never);
93
+ mockUseConfig.mockReturnValue({
94
+ groupSessionConcepts: {
95
+ sessionName: 'concept-session-name',
96
+ practitionerName: 'concept-practitioner',
97
+ sessionNotes: 'concept-notes',
98
+ sessionDate: 'concept-date',
99
+ cohortId: 'concept-cohort-id',
100
+ cohortName: 'concept-cohort-name',
101
+ sessionUuid: 'concept-session-uuid',
102
+ },
103
+ } as never);
104
+ });
105
+
106
+ it('builds encounter payloads with group-session metadata when no visit exists yet', () => {
107
+ const updateVisitUuid = jest.fn();
108
+ renderWorkspace({ updateVisitUuid });
109
+
110
+ const [formBootstrapProps] = mockFormBootstrap.mock.calls[0];
111
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
112
+ const payload: Record<string, any> = {
113
+ obs: [
114
+ {
115
+ concept: 'weight-concept',
116
+ value: '70',
117
+ groupMembers: [{ concept: 'height-concept', value: '175' }],
118
+ },
119
+ ],
120
+ };
121
+
122
+ act(() => {
123
+ formBootstrapProps.handleEncounterCreate(payload);
124
+ });
125
+
126
+ const expectedObsDatetime = new Date('2026-04-15').toISOString();
127
+
128
+ expect(payload.location).toBe('session-location');
129
+ expect(payload.encounterDatetime).toBe(expectedObsDatetime);
130
+ expect(payload.obs[0]).toEqual(
131
+ expect.objectContaining({
132
+ obsDatetime: expectedObsDatetime,
133
+ groupMembers: [
134
+ expect.objectContaining({
135
+ concept: 'height-concept',
136
+ value: '175',
137
+ obsDatetime: expectedObsDatetime,
138
+ }),
139
+ ],
140
+ }),
141
+ );
142
+ expect(payload.obs).toEqual(
143
+ expect.arrayContaining([
144
+ expect.objectContaining({ concept: 'concept-session-name', value: 'April Session' }),
145
+ expect.objectContaining({ concept: 'concept-practitioner', value: 'Alice' }),
146
+ expect.objectContaining({ concept: 'concept-notes', value: 'Bring notebooks' }),
147
+ expect.objectContaining({ concept: 'concept-date', value: '2026-04-15' }),
148
+ expect.objectContaining({ concept: 'concept-cohort-id', value: 'group-1' }),
149
+ expect.objectContaining({ concept: 'concept-cohort-name', value: 'Nutrition Cohort' }),
150
+ expect.objectContaining({ concept: 'concept-session-uuid', value: 'session-1' }),
151
+ ]),
152
+ );
153
+ expect(payload.visit).toEqual({
154
+ startDatetime: '2026-04-15',
155
+ stopDatetime: '2026-04-15',
156
+ uuid: 'generated-visit-uuid',
157
+ patient: {
158
+ uuid: 'patient-a',
159
+ },
160
+ location: {
161
+ uuid: 'session-location',
162
+ },
163
+ visitType: {
164
+ uuid: 'visit-type-1',
165
+ },
166
+ });
167
+ expect(updateVisitUuid).toHaveBeenCalledWith('generated-visit-uuid');
168
+ });
169
+
170
+ it('wires patient switching and save actions through the workflow callbacks', async () => {
171
+ const user = userEvent.setup();
172
+ const saveEncounter = jest.fn();
173
+ const submitForNext = jest.fn();
174
+ renderWorkspace({ saveEncounter, submitForNext });
175
+
176
+ const [formBootstrapProps] = mockFormBootstrap.mock.calls[0];
177
+
178
+ act(() => {
179
+ formBootstrapProps.handlePostResponse({ uuid: 'encounter-1' });
180
+ });
181
+
182
+ expect(saveEncounter).toHaveBeenCalledWith('encounter-1');
183
+
184
+ await user.click(screen.getByTestId('patient-card-patient-b'));
185
+ expect(submitForNext).toHaveBeenCalledWith('patient-b');
186
+
187
+ await user.click(screen.getByRole('button', { name: 'Next patient' }));
188
+ expect(submitForNext).toHaveBeenCalledWith();
189
+ });
190
+ });
@@ -0,0 +1,94 @@
1
+ import React, { useEffect } from 'react';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { useFormContext } from 'react-hook-form';
5
+ import GroupFormWorkflowContext from '../context/GroupFormWorkflowContext';
6
+ import SessionMetaWorkspace from './SessionMetaWorkspace';
7
+
8
+ jest.mock('../CancelModal', () => ({
9
+ __esModule: true,
10
+ default: () => null,
11
+ }));
12
+
13
+ jest.mock('./SessionDetailsForm', () => ({
14
+ __esModule: true,
15
+ default: function MockSessionDetailsForm() {
16
+ const { register, setValue } = useFormContext();
17
+
18
+ useEffect(() => {
19
+ setValue('sessionName', 'April Session');
20
+ setValue('practitionerName', 'Alice');
21
+ setValue('sessionDate', ['2026-04-15']);
22
+ setValue('sessionNotes', 'Bring notebooks');
23
+ }, [setValue]);
24
+
25
+ return (
26
+ <>
27
+ <input aria-label="Session Name" {...register('sessionName', { required: true })} />
28
+ <input aria-label="Practitioner Name" {...register('practitionerName', { required: true })} />
29
+ <input aria-label="Session Notes" {...register('sessionNotes', { required: true })} />
30
+ </>
31
+ );
32
+ },
33
+ }));
34
+
35
+ const renderSessionMetaWorkspace = (contextOverrides = {}) =>
36
+ render(
37
+ <GroupFormWorkflowContext.Provider
38
+ value={
39
+ {
40
+ workflowState: 'NEW_GROUP_SESSION',
41
+ patientUuids: ['patient-a'],
42
+ activeGroupUuid: 'group-1',
43
+ setSessionMeta: jest.fn(),
44
+ ...contextOverrides,
45
+ } as never
46
+ }
47
+ >
48
+ <SessionMetaWorkspace />
49
+ </GroupFormWorkflowContext.Provider>,
50
+ );
51
+
52
+ describe('SessionMetaWorkspace', () => {
53
+ it('submits the session metadata with the normalized session date', async () => {
54
+ const user = userEvent.setup();
55
+ const setSessionMeta = jest.fn();
56
+ renderSessionMetaWorkspace({ setSessionMeta });
57
+
58
+ await user.click(screen.getByRole('button', { name: 'Create New Session' }));
59
+
60
+ await waitFor(() => {
61
+ expect(setSessionMeta).toHaveBeenCalledWith(
62
+ expect.objectContaining({
63
+ groupUuid: 'group-1',
64
+ sessionName: 'April Session',
65
+ practitionerName: 'Alice',
66
+ sessionDate: '2026-04-15',
67
+ sessionNotes: 'Bring notebooks',
68
+ }),
69
+ );
70
+ });
71
+ });
72
+
73
+ it('shows the group selection error when submitted without a chosen group', async () => {
74
+ const user = userEvent.setup();
75
+ const setSessionMeta = jest.fn();
76
+ renderSessionMetaWorkspace({
77
+ activeGroupUuid: null,
78
+ setSessionMeta,
79
+ });
80
+
81
+ await user.click(screen.getByRole('button', { name: 'Create New Session' }));
82
+
83
+ expect(await screen.findByText('Please choose a group.')).toBeInTheDocument();
84
+ expect(setSessionMeta).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it('disables session creation until the participant list is populated', () => {
88
+ renderSessionMetaWorkspace({
89
+ patientUuids: [],
90
+ });
91
+
92
+ expect(screen.getByRole('button', { name: 'Create New Session' })).toBeDisabled();
93
+ });
94
+ });