@openmrs/esm-patient-list-management-app 9.2.1-pre.7288 → 9.2.1-pre.7296

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,212 @@
1
+ import React from 'react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { render, screen } from '@testing-library/react';
4
+ import {
5
+ getCoreTranslation,
6
+ showSnackbar,
7
+ useLayoutType,
8
+ useSession,
9
+ type Workspace2DefinitionProps,
10
+ } from '@openmrs/esm-framework';
11
+ import { mockSession } from '__mocks__';
12
+ import type { OpenmrsCohort, CohortType } from '../api/types';
13
+ import PatientListFormWorkspace from './patient-list-form.workspace';
14
+ import type { PatientListFormWorkspaceProps } from './patient-list-form.workspace';
15
+
16
+ const mockCreatePatientList = jest.fn();
17
+ const mockEditPatientList = jest.fn();
18
+ const mockExtractErrorMessagesFromResponse = jest.fn();
19
+
20
+ jest.mock('../api/patient-list.resource', () => ({
21
+ createPatientList: (...args: unknown[]) => mockCreatePatientList(...args),
22
+ editPatientList: (...args: unknown[]) => mockEditPatientList(...args),
23
+ extractErrorMessagesFromResponse: (...args: unknown[]) => mockExtractErrorMessagesFromResponse(...args),
24
+ }));
25
+
26
+ const mockUseCohortTypes = jest.fn();
27
+
28
+ jest.mock('../api/hooks', () => ({
29
+ useCohortTypes: () => mockUseCohortTypes(),
30
+ }));
31
+
32
+ const mockUseSession = jest.mocked(useSession);
33
+ const mockUseLayoutType = jest.mocked(useLayoutType);
34
+ const mockShowSnackbar = jest.mocked(showSnackbar);
35
+ const mockGetCoreTranslation = jest.mocked(getCoreTranslation);
36
+
37
+ const mockCloseWorkspace = jest.fn();
38
+ const mockOnSuccess = jest.fn();
39
+
40
+ const mockCohortTypes: CohortType[] = [
41
+ { uuid: 'type-1', display: 'My List' },
42
+ { uuid: 'type-2', display: 'System List' },
43
+ ];
44
+
45
+ const mockPatientListDetails: OpenmrsCohort = {
46
+ uuid: 'test-list-uuid',
47
+ name: 'Test Patient List',
48
+ description: 'A test patient list description',
49
+ cohortType: { uuid: 'type-1', display: 'My List' },
50
+ resourceVersion: '1.0',
51
+ attributes: [],
52
+ links: [],
53
+ location: null,
54
+ groupCohort: false,
55
+ startDate: '2023-01-01',
56
+ endDate: null,
57
+ voidReason: null,
58
+ voided: false,
59
+ size: 5,
60
+ };
61
+
62
+ function createMockWorkspace2Props(
63
+ workspaceProps: PatientListFormWorkspaceProps | null = null,
64
+ ): Workspace2DefinitionProps<PatientListFormWorkspaceProps> {
65
+ return {
66
+ workspaceProps,
67
+ closeWorkspace: mockCloseWorkspace,
68
+ windowProps: null,
69
+ groupProps: null,
70
+ workspaceName: 'patient-list-form-workspace',
71
+ isRootWorkspace: true,
72
+ launchChildWorkspace: jest.fn(),
73
+ } as unknown as Workspace2DefinitionProps<PatientListFormWorkspaceProps>;
74
+ }
75
+
76
+ describe('PatientListFormWorkspace', () => {
77
+ beforeEach(() => {
78
+ mockUseSession.mockReturnValue(mockSession.data);
79
+ mockUseLayoutType.mockReturnValue('small-desktop');
80
+ mockUseCohortTypes.mockReturnValue({
81
+ listCohortTypes: mockCohortTypes,
82
+ isLoading: false,
83
+ error: null,
84
+ mutate: jest.fn(),
85
+ });
86
+ mockGetCoreTranslation.mockImplementation((key) => {
87
+ const translations: Record<string, string> = {
88
+ cancel: 'Cancel',
89
+ };
90
+ return translations[key] || key;
91
+ });
92
+ mockCreatePatientList.mockResolvedValue({});
93
+ mockEditPatientList.mockResolvedValue({});
94
+ mockExtractErrorMessagesFromResponse.mockImplementation((err) => err?.error?.message || 'Unknown error');
95
+ });
96
+
97
+ it('renders the create new patient list form correctly', () => {
98
+ const props = createMockWorkspace2Props({ onSuccess: mockOnSuccess });
99
+
100
+ render(<PatientListFormWorkspace {...props} />);
101
+
102
+ expect(screen.getByText(/configure your patient list/i)).toBeInTheDocument();
103
+ expect(screen.getByLabelText(/list name/i)).toBeInTheDocument();
104
+ expect(screen.getByText(/select cohort type/i)).toBeInTheDocument();
105
+ expect(screen.getByLabelText(/describe the purpose of this list/i)).toBeInTheDocument();
106
+ expect(screen.getByRole('button', { name: /create list/i })).toBeInTheDocument();
107
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
108
+ });
109
+
110
+ it('renders the edit patient list form with existing data', () => {
111
+ const props = createMockWorkspace2Props({
112
+ patientListDetails: mockPatientListDetails,
113
+ onSuccess: mockOnSuccess,
114
+ });
115
+
116
+ render(<PatientListFormWorkspace {...props} />);
117
+
118
+ expect(screen.getByLabelText(/list name/i)).toHaveValue('Test Patient List');
119
+ expect(screen.getByLabelText(/describe the purpose of this list/i)).toHaveValue('A test patient list description');
120
+ expect(screen.getByRole('button', { name: /edit list/i })).toBeInTheDocument();
121
+ });
122
+
123
+ it('creates a new patient list successfully', async () => {
124
+ const user = userEvent.setup();
125
+ const props = createMockWorkspace2Props({ onSuccess: mockOnSuccess });
126
+
127
+ render(<PatientListFormWorkspace {...props} />);
128
+
129
+ await user.type(screen.getByLabelText(/list name/i), 'My New Patient List');
130
+ await user.type(screen.getByLabelText(/describe the purpose of this list/i), 'A description for testing');
131
+ await user.click(screen.getByRole('button', { name: /create list/i }));
132
+
133
+ expect(mockCreatePatientList).toHaveBeenCalledWith(
134
+ expect.objectContaining({
135
+ name: 'My New Patient List',
136
+ description: 'A description for testing',
137
+ }),
138
+ );
139
+
140
+ expect(mockShowSnackbar).toHaveBeenCalledWith(
141
+ expect.objectContaining({
142
+ kind: 'success',
143
+ title: 'Created',
144
+ }),
145
+ );
146
+
147
+ expect(mockOnSuccess).toHaveBeenCalled();
148
+ expect(mockCloseWorkspace).toHaveBeenCalledWith({ discardUnsavedChanges: true });
149
+ });
150
+
151
+ it('edits an existing patient list successfully', async () => {
152
+ const user = userEvent.setup();
153
+ const props = createMockWorkspace2Props({
154
+ patientListDetails: mockPatientListDetails,
155
+ onSuccess: mockOnSuccess,
156
+ });
157
+
158
+ render(<PatientListFormWorkspace {...props} />);
159
+
160
+ const nameInput = screen.getByLabelText(/list name/i);
161
+ await user.clear(nameInput);
162
+ await user.type(nameInput, 'Updated List Name');
163
+ await user.click(screen.getByRole('button', { name: /edit list/i }));
164
+
165
+ expect(mockEditPatientList).toHaveBeenCalledWith(
166
+ 'test-list-uuid',
167
+ expect.objectContaining({
168
+ name: 'Updated List Name',
169
+ }),
170
+ );
171
+
172
+ expect(mockShowSnackbar).toHaveBeenCalledWith(
173
+ expect.objectContaining({
174
+ kind: 'success',
175
+ title: 'Updated',
176
+ }),
177
+ );
178
+
179
+ expect(mockOnSuccess).toHaveBeenCalled();
180
+ expect(mockCloseWorkspace).toHaveBeenCalledWith({ discardUnsavedChanges: true });
181
+ });
182
+
183
+ it('calls closeWorkspace when cancel button is clicked', async () => {
184
+ const user = userEvent.setup();
185
+ const props = createMockWorkspace2Props({ onSuccess: mockOnSuccess });
186
+
187
+ render(<PatientListFormWorkspace {...props} />);
188
+
189
+ await user.click(screen.getByRole('button', { name: /cancel/i }));
190
+
191
+ expect(mockCloseWorkspace).toHaveBeenCalled();
192
+ });
193
+
194
+ it('shows error snackbar when creation fails', async () => {
195
+ mockCreatePatientList.mockRejectedValueOnce(new Error('Network error'));
196
+
197
+ const user = userEvent.setup();
198
+ const props = createMockWorkspace2Props({ onSuccess: mockOnSuccess });
199
+
200
+ render(<PatientListFormWorkspace {...props} />);
201
+
202
+ await user.type(screen.getByLabelText(/list name/i), 'Test List');
203
+ await user.click(screen.getByRole('button', { name: /create list/i }));
204
+
205
+ expect(mockShowSnackbar).toHaveBeenCalledWith(
206
+ expect.objectContaining({
207
+ kind: 'error',
208
+ title: expect.stringMatching(/error creating list/i),
209
+ }),
210
+ );
211
+ });
212
+ });
@@ -1,4 +1,4 @@
1
- import React, { useCallback, type SyntheticEvent, useEffect, useId, useState } from 'react';
1
+ import React, { useCallback, type SyntheticEvent, useEffect, useId, useMemo, useState } from 'react';
2
2
  import { Button, ButtonSet, Dropdown, Layer, TextArea, TextInput } from '@carbon/react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { type TFunction } from 'i18next';
@@ -9,7 +9,8 @@ import {
9
9
  showSnackbar,
10
10
  useLayoutType,
11
11
  useSession,
12
- type DefaultWorkspaceProps,
12
+ Workspace2,
13
+ type Workspace2DefinitionProps,
13
14
  } from '@openmrs/esm-framework';
14
15
  import type { NewCohortData, NewCohortDataPayload, OpenmrsCohort } from '../api/types';
15
16
  import {
@@ -36,16 +37,18 @@ const createCohortSchema = (t: TFunction) => {
36
37
 
37
38
  type CohortFormData = z.infer<ReturnType<typeof createCohortSchema>>;
38
39
 
39
- export interface PatientListFormWorkspaceProps extends DefaultWorkspaceProps {
40
+ export interface PatientListFormWorkspaceProps {
41
+ /** Existing patient list to edit. If not provided, creates a new list. */
40
42
  patientListDetails?: OpenmrsCohort;
43
+ /** Callback triggered after successful create/edit operation */
41
44
  onSuccess?: () => void;
42
45
  }
43
46
 
44
- const PatientListFormWorkspace: React.FC<PatientListFormWorkspaceProps> = ({
45
- patientListDetails,
46
- onSuccess = () => {},
47
+ const PatientListFormWorkspace: React.FC<Workspace2DefinitionProps<PatientListFormWorkspaceProps>> = ({
48
+ workspaceProps,
47
49
  closeWorkspace,
48
50
  }) => {
51
+ const { patientListDetails, onSuccess = () => {} } = workspaceProps ?? {};
49
52
  const id = useId();
50
53
  const isTablet = useLayoutType() === 'tablet';
51
54
  const responsiveLevel = isTablet ? 1 : 0;
@@ -63,6 +66,19 @@ const PatientListFormWorkspace: React.FC<PatientListFormWorkspaceProps> = ({
63
66
  });
64
67
  const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
65
68
 
69
+ const { initialValues, isDirty } = useMemo(() => {
70
+ const initial = {
71
+ name: patientListDetails?.name || '',
72
+ description: patientListDetails?.description || '',
73
+ cohortType: patientListDetails?.cohortType?.uuid || '',
74
+ };
75
+ const dirty =
76
+ cohortDetails.name !== initial.name ||
77
+ cohortDetails.description !== initial.description ||
78
+ cohortDetails.cohortType !== initial.cohortType;
79
+ return { initialValues: initial, isDirty: dirty };
80
+ }, [cohortDetails, patientListDetails]);
81
+
66
82
  const validateForm = useCallback(
67
83
  (data: CohortFormData) => {
68
84
  try {
@@ -129,7 +145,7 @@ const PatientListFormWorkspace: React.FC<PatientListFormWorkspaceProps> = ({
129
145
  });
130
146
  onSuccess();
131
147
  setIsSubmitting(false);
132
- closeWorkspace();
148
+ closeWorkspace({ discardUnsavedChanges: true });
133
149
  })
134
150
  .catch(onError);
135
151
  } else {
@@ -146,7 +162,7 @@ const PatientListFormWorkspace: React.FC<PatientListFormWorkspaceProps> = ({
146
162
  });
147
163
  onSuccess();
148
164
  setIsSubmitting(false);
149
- closeWorkspace();
165
+ closeWorkspace({ discardUnsavedChanges: true });
150
166
  })
151
167
  .catch(onError);
152
168
  }
@@ -159,75 +175,85 @@ const PatientListFormWorkspace: React.FC<PatientListFormWorkspaceProps> = ({
159
175
  }));
160
176
  }, []);
161
177
 
178
+ const workspaceTitle = patientListDetails
179
+ ? t('editPatientListHeader', 'Edit patient list')
180
+ : t('newPatientListHeader', 'New patient list');
181
+
182
+ const handleCancel = useCallback(() => {
183
+ closeWorkspace();
184
+ }, [closeWorkspace]);
185
+
162
186
  return (
163
- <div data-tutorial-target="patient-list-form" className={styles.container}>
164
- {/* data-tutorial-target attribute is essential for joyride in onboarding app ! */}
165
- <div className={styles.content}>
166
- <h4 className={styles.header}>{t('configureList', 'Configure your patient list using the fields below')}</h4>
167
- <div>
168
- <Layer level={responsiveLevel}>
169
- <TextInput
170
- id={`${id}-input`}
171
- invalid={!!validationErrors.name}
172
- invalidText={validationErrors.name}
173
- labelText={t('newPatientListNameLabel', 'List name')}
174
- name="name"
175
- onChange={handleChange}
176
- placeholder={t('listNamePlaceholder', 'e.g. Potential research participants')}
177
- value={cohortDetails?.name}
178
- />
179
- </Layer>
180
- </div>
181
- <div className={styles.input}>
182
- <Layer level={responsiveLevel}>
183
- <Dropdown
184
- id="cohortType"
185
- items={listCohortTypes}
186
- itemToString={(item) => (item ? item.display : '')}
187
- label={t('chooseCohortType', 'Choose cohort type')}
188
- onChange={({ selectedItem }) => {
189
- setCohortDetails((prev) => ({
190
- ...prev,
191
- cohortType: selectedItem?.uuid || '',
192
- }));
193
- }}
194
- selectedItem={listCohortTypes.find((item) => item.uuid === cohortDetails.cohortType) || null}
195
- titleText={t('selectCohortType', 'Select cohort type')}
196
- type="default"
197
- />
198
- </Layer>
199
- </div>
200
- <div className={styles.input}>
201
- <Layer level={responsiveLevel}>
202
- <TextArea
203
- enableCounter
204
- id={`${id}-textarea`}
205
- labelText={t('newPatientListDescriptionLabel', 'Describe the purpose of this list in a few words')}
206
- maxCount={255}
207
- name="description"
208
- onChange={handleChange}
209
- placeholder={t(
210
- 'listDescriptionPlaceholder',
211
- 'e.g. Patients with diagnosed asthma who may be willing to be a part of a university research study',
212
- )}
213
- value={cohortDetails?.description}
214
- />
215
- </Layer>
187
+ <Workspace2 title={workspaceTitle} hasUnsavedChanges={isDirty}>
188
+ <div data-tutorial-target="patient-list-form" className={styles.container}>
189
+ {/* data-tutorial-target attribute is essential for joyride in onboarding app ! */}
190
+ <div className={styles.content}>
191
+ <h4 className={styles.header}>{t('configureList', 'Configure your patient list using the fields below')}</h4>
192
+ <div>
193
+ <Layer level={responsiveLevel}>
194
+ <TextInput
195
+ id={`${id}-input`}
196
+ invalid={!!validationErrors.name}
197
+ invalidText={validationErrors.name}
198
+ labelText={t('newPatientListNameLabel', 'List name')}
199
+ name="name"
200
+ onChange={handleChange}
201
+ placeholder={t('listNamePlaceholder', 'e.g. Potential research participants')}
202
+ value={cohortDetails?.name}
203
+ />
204
+ </Layer>
205
+ </div>
206
+ <div className={styles.input}>
207
+ <Layer level={responsiveLevel}>
208
+ <Dropdown
209
+ id="cohortType"
210
+ items={listCohortTypes}
211
+ itemToString={(item) => (item ? item.display : '')}
212
+ label={t('chooseCohortType', 'Choose cohort type')}
213
+ onChange={({ selectedItem }) => {
214
+ setCohortDetails((prev) => ({
215
+ ...prev,
216
+ cohortType: selectedItem?.uuid || '',
217
+ }));
218
+ }}
219
+ selectedItem={listCohortTypes.find((item) => item.uuid === cohortDetails.cohortType) || null}
220
+ titleText={t('selectCohortType', 'Select cohort type')}
221
+ type="default"
222
+ />
223
+ </Layer>
224
+ </div>
225
+ <div className={styles.input}>
226
+ <Layer level={responsiveLevel}>
227
+ <TextArea
228
+ enableCounter
229
+ id={`${id}-textarea`}
230
+ labelText={t('newPatientListDescriptionLabel', 'Describe the purpose of this list in a few words')}
231
+ maxCount={255}
232
+ name="description"
233
+ onChange={handleChange}
234
+ placeholder={t(
235
+ 'listDescriptionPlaceholder',
236
+ 'e.g. Patients with diagnosed asthma who may be willing to be a part of a university research study',
237
+ )}
238
+ value={cohortDetails?.description}
239
+ />
240
+ </Layer>
241
+ </div>
216
242
  </div>
243
+ <ButtonSet className={styles.buttonSet}>
244
+ <Button className={styles.button} onClick={handleCancel} kind="secondary" size="xl">
245
+ {getCoreTranslation('cancel')}
246
+ </Button>
247
+ <Button className={styles.button} onClick={handleSubmit} size="xl" disabled={isSubmitting}>
248
+ {isSubmitting
249
+ ? t('submitting', 'Submitting')
250
+ : patientListDetails
251
+ ? t('editList', 'Edit list')
252
+ : t('createList', 'Create list')}
253
+ </Button>
254
+ </ButtonSet>
217
255
  </div>
218
- <ButtonSet className={styles.buttonSet}>
219
- <Button className={styles.button} onClick={closeWorkspace} kind="secondary" size="xl">
220
- {getCoreTranslation('cancel')}
221
- </Button>
222
- <Button className={styles.button} onClick={handleSubmit} size="xl" disabled={isSubmitting}>
223
- {isSubmitting
224
- ? t('submitting', 'Submitting')
225
- : patientListDetails
226
- ? t('editList', 'Edit list')
227
- : t('createList', 'Create list')}
228
- </Button>
229
- </ButtonSet>
230
- </div>
256
+ </Workspace2>
231
257
  );
232
258
  };
233
259
 
@@ -1,7 +1,7 @@
1
1
  import React, { useEffect, useRef } from 'react';
2
2
  import { BrowserRouter, Route, Routes, useSearchParams } from 'react-router-dom';
3
3
  import { useTranslation } from 'react-i18next';
4
- import { WorkspaceContainer, launchWorkspace } from '@openmrs/esm-framework';
4
+ import { WorkspaceContainer, launchWorkspace2 } from '@openmrs/esm-framework';
5
5
  import ListDetails from './list-details/list-details.component';
6
6
  import ListsDashboard from './lists-dashboard/lists-dashboard.component';
7
7
 
@@ -15,9 +15,7 @@ const AutoLaunchPatientListWorkspace: React.FC = () => {
15
15
  if (shouldOpenCreate && !hasOpenedRef.current) {
16
16
  hasOpenedRef.current = true;
17
17
  const rafId = requestAnimationFrame(() => {
18
- launchWorkspace('patient-list-form-workspace', {
19
- workspaceTitle: t('newPatientListHeader', 'New patient list'),
20
- });
18
+ launchWorkspace2('patient-list-form-workspace');
21
19
  setSearchParams({}, { replace: true });
22
20
  });
23
21
  return () => cancelAnimationFrame(rafId);
package/src/routes.json CHANGED
@@ -43,12 +43,23 @@
43
43
  "component": "deletePatientListModal"
44
44
  }
45
45
  ],
46
- "workspaces": [
46
+ "workspaces2": [
47
47
  {
48
48
  "name": "patient-list-form-workspace",
49
49
  "component": "patientListFormWorkspace",
50
- "title": "patientListFormHeader",
51
- "type": "patient-lists"
50
+ "window": "patient-list-form-window"
51
+ }
52
+ ],
53
+ "workspaceWindows2": [
54
+ {
55
+ "name": "patient-list-form-window",
56
+ "group": "patient-list-form-workspace-group"
57
+ }
58
+ ],
59
+ "workspaceGroups2": [
60
+ {
61
+ "name": "patient-list-form-workspace-group",
62
+ "overlay": false
52
63
  }
53
64
  ]
54
65
  }