@kenyaemr/esm-patient-list-management-app 8.1.1-pre.114 → 8.1.1-pre.116
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 +18 -18
- package/dist/130.js +1 -1
- package/dist/130.js.map +1 -1
- package/dist/271.js +1 -1
- package/dist/319.js +1 -1
- package/dist/37.js +1 -1
- package/dist/37.js.map +1 -1
- package/dist/455.js +1 -0
- package/dist/455.js.map +1 -0
- package/dist/460.js +1 -1
- package/dist/574.js +1 -1
- package/dist/644.js +1 -1
- package/dist/658.js +2 -0
- package/dist/658.js.map +1 -0
- package/dist/757.js +1 -1
- package/dist/788.js +1 -1
- package/dist/807.js +1 -1
- package/dist/833.js +1 -1
- package/dist/kenyaemr-esm-patient-list-management-app.js +1 -1
- package/dist/kenyaemr-esm-patient-list-management-app.js.buildmanifest.json +84 -84
- package/dist/kenyaemr-esm-patient-list-management-app.js.map +1 -1
- package/dist/main.js +1 -1
- package/dist/main.js.map +1 -1
- package/dist/routes.json +1 -1
- package/package.json +1 -1
- package/src/add-patient/add-patient.component.tsx +81 -152
- package/src/add-patient/add-patient.scss +73 -4
- package/src/add-patient/add-patient.test.tsx +100 -0
- package/src/add-patient-to-patient-list-menu-item.component.tsx +1 -0
- package/src/add-patient-to-patient-list-menu-item.test.tsx +5 -1
- package/src/api/api-remote.ts +90 -2
- package/src/api/types.ts +7 -0
- package/src/lists-dashboard/lists-dashboard.component.tsx +22 -16
- package/src/lists-dashboard/lists-dashboard.scss +6 -0
- package/src/routes.json +6 -4
- package/translations/am.json +0 -1
- package/translations/ar.json +0 -1
- package/translations/en.json +2 -3
- package/translations/es.json +0 -1
- package/translations/fr.json +35 -36
- package/translations/he.json +0 -1
- package/translations/km.json +0 -1
- package/translations/zh.json +0 -1
- package/translations/zh_CN.json +0 -1
- package/dist/112.js +0 -1
- package/dist/112.js.map +0 -1
- package/dist/592.js +0 -2
- package/dist/592.js.map +0 -1
- package/src/header/header.component.tsx +0 -52
- package/src/illo.component.tsx +0 -25
- /package/dist/{592.js.LICENSE.txt → 658.js.LICENSE.txt} +0 -0
package/dist/routes.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"$schema":"https://json.openmrs.org/routes.schema.json","backendDependencies":{"webservices.rest":"^2.2.0"},"extensions":[{"name":"patient-lists-dashboard-link","component":"patientListDashboardLink","slot":"homepage-dashboard-slot","meta":{"name":"patient-lists","slot":"patient-lists-dashboard-slot","title":"Patient lists"}},{"component":"root","name":"patient-lists-dashboard","slot":"patient-lists-dashboard-slot"},{"name":"list-details-table","component":"listDetailsTable"},{"name":"add-patient-to-patient-list-
|
|
1
|
+
{"$schema":"https://json.openmrs.org/routes.schema.json","backendDependencies":{"webservices.rest":"^2.2.0"},"extensions":[{"name":"patient-lists-dashboard-link","component":"patientListDashboardLink","slot":"homepage-dashboard-slot","meta":{"name":"patient-lists","slot":"patient-lists-dashboard-slot","title":"Patient lists"}},{"component":"root","name":"patient-lists-dashboard","slot":"patient-lists-dashboard-slot"},{"name":"list-details-table","component":"listDetailsTable"},{"name":"add-patient-to-patient-list-button","slot":"patient-actions-slot","component":"addPatientToPatientListMenuItem"}],"modals":[{"name":"add-patient-to-patient-list-modal","component":"addPatientToListModal"}],"version":"8.1.1-pre.116"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kenyaemr/esm-patient-list-management-app",
|
|
3
|
-
"version": "8.1.1-pre.
|
|
3
|
+
"version": "8.1.1-pre.116",
|
|
4
4
|
"description": "Microfrontend for managing patient lists in O3",
|
|
5
5
|
"browser": "dist/kenyaemr-esm-patient-list-management-app.js",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -1,21 +1,11 @@
|
|
|
1
1
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
2
2
|
import classNames from 'classnames';
|
|
3
|
+
import { mutate } from 'swr';
|
|
3
4
|
import { useTranslation } from 'react-i18next';
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
getDynamicOfflineDataEntries,
|
|
9
|
-
putDynamicOfflineData,
|
|
10
|
-
syncDynamicOfflineData,
|
|
11
|
-
showSnackbar,
|
|
12
|
-
toOmrsIsoString,
|
|
13
|
-
usePagination,
|
|
14
|
-
navigate,
|
|
15
|
-
useConfig,
|
|
16
|
-
} from '@openmrs/esm-framework';
|
|
17
|
-
import { addPatientToList, getAllPatientLists, getPatientListIdsForPatient } from '../api/api-remote';
|
|
18
|
-
import { type ConfigSchema } from '../config-schema';
|
|
5
|
+
import { Button, Checkbox, CheckboxSkeleton, Pagination, Search, Tile } from '@carbon/react';
|
|
6
|
+
import { navigate, restBaseUrl, showSnackbar, usePagination } from '@openmrs/esm-framework';
|
|
7
|
+
import { type AddablePatientListViewModel } from '../api/types';
|
|
8
|
+
import { useAddablePatientLists } from '../api/api-remote';
|
|
19
9
|
import styles from './add-patient.scss';
|
|
20
10
|
|
|
21
11
|
interface AddPatientProps {
|
|
@@ -23,26 +13,19 @@ interface AddPatientProps {
|
|
|
23
13
|
patientUuid: string;
|
|
24
14
|
}
|
|
25
15
|
|
|
26
|
-
interface AddablePatientListViewModel {
|
|
27
|
-
addPatient(): Promise<void>;
|
|
28
|
-
displayName: string;
|
|
29
|
-
checked?: boolean;
|
|
30
|
-
id: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
16
|
const AddPatient: React.FC<AddPatientProps> = ({ closeModal, patientUuid }) => {
|
|
34
17
|
const { t } = useTranslation();
|
|
18
|
+
const { data, isLoading } = useAddablePatientLists(patientUuid);
|
|
35
19
|
const [searchValue, setSearchValue] = useState('');
|
|
36
20
|
const [selected, setSelected] = useState<Array<string>>([]);
|
|
37
|
-
const { data, isLoading } = useAddablePatientLists(patientUuid);
|
|
38
21
|
|
|
39
|
-
const handleCreateNewList = () => {
|
|
22
|
+
const handleCreateNewList = useCallback(() => {
|
|
40
23
|
navigate({
|
|
41
24
|
to: window.getOpenmrsSpaBase() + 'home/patient-lists?new_cohort=true',
|
|
42
25
|
});
|
|
43
26
|
|
|
44
27
|
closeModal();
|
|
45
|
-
};
|
|
28
|
+
}, [closeModal]);
|
|
46
29
|
|
|
47
30
|
const handleSelectionChanged = useCallback((patientListId: string, listSelected: boolean) => {
|
|
48
31
|
if (listSelected) {
|
|
@@ -52,34 +35,39 @@ const AddPatient: React.FC<AddPatientProps> = ({ closeModal, patientUuid }) => {
|
|
|
52
35
|
}
|
|
53
36
|
}, []);
|
|
54
37
|
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
const patientList = data.find((list) => list.id === selectedId);
|
|
58
|
-
if (!patientList) {
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
38
|
+
const mutateCohortMembers = useCallback(() => {
|
|
39
|
+
const key = `${restBaseUrl}/cohortm/cohortmember?patient=${patientUuid}&v=custom:(uuid,patient:ref,cohort:(uuid,name,startDate,endDate))`;
|
|
61
40
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
.then(() =>
|
|
65
|
-
showSnackbar({
|
|
66
|
-
title: t('successfullyAdded', 'Successfully added'),
|
|
67
|
-
kind: 'success',
|
|
68
|
-
isLowContrast: true,
|
|
69
|
-
subtitle: `${t('successAddPatientToList', 'Patient added to list')}: ${patientList.displayName}`,
|
|
70
|
-
}),
|
|
71
|
-
)
|
|
72
|
-
.catch(() =>
|
|
73
|
-
showSnackbar({
|
|
74
|
-
title: t('error', 'Error'),
|
|
75
|
-
kind: 'error',
|
|
76
|
-
subtitle: `${t('errorAddPatientToList', 'Patient not added to list')}: ${patientList.displayName}`,
|
|
77
|
-
}),
|
|
78
|
-
);
|
|
79
|
-
}
|
|
41
|
+
return mutate((k) => typeof k === 'string' && k === key);
|
|
42
|
+
}, []);
|
|
80
43
|
|
|
81
|
-
|
|
82
|
-
|
|
44
|
+
const handleSubmit = useCallback(() => {
|
|
45
|
+
Promise.all(
|
|
46
|
+
selected.map((selectedId) => {
|
|
47
|
+
const patientList = data.find((list) => list.id === selectedId);
|
|
48
|
+
if (!patientList) return Promise.resolve();
|
|
49
|
+
|
|
50
|
+
return patientList
|
|
51
|
+
.addPatient()
|
|
52
|
+
.then(async () => {
|
|
53
|
+
await mutateCohortMembers();
|
|
54
|
+
showSnackbar({
|
|
55
|
+
title: t('successfullyAdded', 'Successfully added'),
|
|
56
|
+
kind: 'success',
|
|
57
|
+
isLowContrast: true,
|
|
58
|
+
subtitle: `${t('successAddPatientToList', 'Patient added to list')}: ${patientList.displayName}`,
|
|
59
|
+
});
|
|
60
|
+
})
|
|
61
|
+
.catch(() => {
|
|
62
|
+
showSnackbar({
|
|
63
|
+
title: t('error', 'Error'),
|
|
64
|
+
kind: 'error',
|
|
65
|
+
subtitle: `${t('errorAddPatientToList', 'Patient not added to list')}: ${patientList.displayName}`,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}),
|
|
69
|
+
).finally(closeModal);
|
|
70
|
+
}, [data, selected, closeModal, t, patientUuid]);
|
|
83
71
|
|
|
84
72
|
const searchResults = useMemo(() => {
|
|
85
73
|
if (!data) {
|
|
@@ -94,7 +82,7 @@ const AddPatient: React.FC<AddPatientProps> = ({ closeModal, patientUuid }) => {
|
|
|
94
82
|
return data;
|
|
95
83
|
}, [searchValue, data]);
|
|
96
84
|
|
|
97
|
-
const { results, goTo, currentPage, paginated } = usePagination(searchResults, 5);
|
|
85
|
+
const { results, goTo, currentPage, paginated } = usePagination<AddablePatientListViewModel>(searchResults, 5);
|
|
98
86
|
|
|
99
87
|
useEffect(() => {
|
|
100
88
|
if (currentPage !== 1) {
|
|
@@ -105,41 +93,54 @@ const AddPatient: React.FC<AddPatientProps> = ({ closeModal, patientUuid }) => {
|
|
|
105
93
|
return (
|
|
106
94
|
<div className={styles.modalContent}>
|
|
107
95
|
<div className={styles.modalHeader}>
|
|
108
|
-
<h1 className={styles.
|
|
109
|
-
<h3 className={styles.
|
|
96
|
+
<h1 className={styles.header}>{t('addPatientToList', 'Add patient to list')}</h1>
|
|
97
|
+
<h3 className={styles.subheader}>
|
|
110
98
|
{t('searchForAListToAddThisPatientTo', 'Search for a list to add this patient to.')}
|
|
111
99
|
</h3>
|
|
112
100
|
</div>
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
/>
|
|
123
|
-
</div>
|
|
101
|
+
<Search
|
|
102
|
+
className={styles.search}
|
|
103
|
+
labelText={t('searchForList', 'Search for a list')}
|
|
104
|
+
placeholder={t('searchForList', 'Search for a list')}
|
|
105
|
+
onChange={({ target }) => {
|
|
106
|
+
setSearchValue(target.value);
|
|
107
|
+
}}
|
|
108
|
+
value={searchValue}
|
|
109
|
+
/>
|
|
124
110
|
<div className={styles.patientListList}>
|
|
125
111
|
<fieldset className="cds--fieldset">
|
|
126
|
-
<p className="cds--label">{t('patientLists', 'Patient lists')}</p>
|
|
127
112
|
{!isLoading && results ? (
|
|
128
113
|
results.length > 0 ? (
|
|
129
|
-
|
|
130
|
-
<
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
114
|
+
<>
|
|
115
|
+
<p className="cds--label">{t('patientLists', 'Patient lists')}</p>
|
|
116
|
+
{results.map((patientList) => (
|
|
117
|
+
<div key={patientList.id} className={styles.checkbox}>
|
|
118
|
+
<Checkbox
|
|
119
|
+
key={patientList.id}
|
|
120
|
+
onChange={(e) => handleSelectionChanged(patientList.id, e.target.checked)}
|
|
121
|
+
checked={patientList.checked || selected.includes(patientList.id)}
|
|
122
|
+
disabled={patientList.checked}
|
|
123
|
+
labelText={patientList.displayName}
|
|
124
|
+
id={patientList.id}
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
))}
|
|
128
|
+
</>
|
|
141
129
|
) : (
|
|
142
|
-
<
|
|
130
|
+
<div className={styles.tileContainer}>
|
|
131
|
+
<Tile className={styles.tile}>
|
|
132
|
+
<div className={styles.tileContent}>
|
|
133
|
+
<p className={styles.content}>{t('noMatchingListsFound', 'No matching lists found')}</p>
|
|
134
|
+
<p className={styles.actionText}>
|
|
135
|
+
<span>{t('trySearchingForADifferentList', 'Try searching for a different list')}</span>
|
|
136
|
+
<span>— or —</span>
|
|
137
|
+
<Button kind="ghost" size="sm" onClick={handleCreateNewList}>
|
|
138
|
+
{t('createNewPatientList', 'Create new patient list')}
|
|
139
|
+
</Button>
|
|
140
|
+
</p>
|
|
141
|
+
</div>
|
|
142
|
+
</Tile>
|
|
143
|
+
</div>
|
|
143
144
|
)
|
|
144
145
|
) : (
|
|
145
146
|
<>
|
|
@@ -180,7 +181,7 @@ const AddPatient: React.FC<AddPatientProps> = ({ closeModal, patientUuid }) => {
|
|
|
180
181
|
</div>
|
|
181
182
|
)}
|
|
182
183
|
<div className={styles.buttonSet}>
|
|
183
|
-
<Button kind="ghost" size="xl" onClick={handleCreateNewList}>
|
|
184
|
+
<Button className={styles.createButton} kind="ghost" size="xl" onClick={handleCreateNewList}>
|
|
184
185
|
{t('createNewPatientList', 'Create new patient list')}
|
|
185
186
|
</Button>
|
|
186
187
|
<div>
|
|
@@ -196,76 +197,4 @@ const AddPatient: React.FC<AddPatientProps> = ({ closeModal, patientUuid }) => {
|
|
|
196
197
|
);
|
|
197
198
|
};
|
|
198
199
|
|
|
199
|
-
// This entire modal is a little bit special since it not only displays the "real" patient lists (i.e. data from
|
|
200
|
-
// the cohorts/backend), but also a fake patient list which doesn't really exist in the backend:
|
|
201
|
-
// The offline patient list.
|
|
202
|
-
// When a patient is added to the offline list, that patient should become available offline, i.e.
|
|
203
|
-
// a dynamic offline data entry must be created.
|
|
204
|
-
// This is why the following abstracts away the differences between the real and the fake patient lists.
|
|
205
|
-
// The component doesn't really care about which is which - the only thing that matters is that the
|
|
206
|
-
// data can be fetched and that there is an "add patient" function.
|
|
207
|
-
|
|
208
|
-
export function useAddablePatientLists(patientUuid: string) {
|
|
209
|
-
const { t } = useTranslation();
|
|
210
|
-
const config = useConfig() as ConfigSchema;
|
|
211
|
-
return useSWR(['addablePatientLists', patientUuid], async () => {
|
|
212
|
-
// Using Promise.allSettled instead of Promise.all here because some distros might not have the
|
|
213
|
-
// cohort module installed, leading to the real patient list call failing.
|
|
214
|
-
// In that case we still want to show fake lists and *not* error out here.
|
|
215
|
-
const [fakeLists, realLists] = await Promise.allSettled([
|
|
216
|
-
findFakePatientListsWithoutPatient(patientUuid, t),
|
|
217
|
-
findRealPatientListsWithoutPatient(patientUuid, config.myListCohortTypeUUID, config.systemListCohortTypeUUID),
|
|
218
|
-
]);
|
|
219
|
-
|
|
220
|
-
return [
|
|
221
|
-
...(fakeLists.status === 'fulfilled' ? fakeLists.value : []),
|
|
222
|
-
...(realLists.status === 'fulfilled' ? realLists.value : []),
|
|
223
|
-
];
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
async function findRealPatientListsWithoutPatient(
|
|
228
|
-
patientUuid: string,
|
|
229
|
-
myListCohortUUID,
|
|
230
|
-
systemListCohortType,
|
|
231
|
-
): Promise<Array<AddablePatientListViewModel>> {
|
|
232
|
-
const [allLists, listsIdsOfThisPatient] = await Promise.all([
|
|
233
|
-
getAllPatientLists({}, myListCohortUUID, systemListCohortType),
|
|
234
|
-
getPatientListIdsForPatient(patientUuid),
|
|
235
|
-
]);
|
|
236
|
-
|
|
237
|
-
return allLists.map((list) => ({
|
|
238
|
-
id: list.id,
|
|
239
|
-
displayName: list.display,
|
|
240
|
-
checked: listsIdsOfThisPatient.includes(list.id),
|
|
241
|
-
async addPatient() {
|
|
242
|
-
await addPatientToList({
|
|
243
|
-
cohort: list.id,
|
|
244
|
-
patient: patientUuid,
|
|
245
|
-
startDate: toOmrsIsoString(new Date()),
|
|
246
|
-
});
|
|
247
|
-
},
|
|
248
|
-
}));
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
async function findFakePatientListsWithoutPatient(
|
|
252
|
-
patientUuid: string,
|
|
253
|
-
t: TFunction,
|
|
254
|
-
): Promise<Array<AddablePatientListViewModel>> {
|
|
255
|
-
const offlinePatients = await getDynamicOfflineDataEntries('patient');
|
|
256
|
-
const isPatientOnOfflineList = offlinePatients.some((x) => x.identifier === patientUuid);
|
|
257
|
-
return isPatientOnOfflineList
|
|
258
|
-
? []
|
|
259
|
-
: [
|
|
260
|
-
{
|
|
261
|
-
id: 'fake-offline-patient-list',
|
|
262
|
-
displayName: t('offlinePatients', 'Offline patients'),
|
|
263
|
-
async addPatient() {
|
|
264
|
-
await putDynamicOfflineData('patient', patientUuid);
|
|
265
|
-
await syncDynamicOfflineData('patient', patientUuid);
|
|
266
|
-
},
|
|
267
|
-
},
|
|
268
|
-
];
|
|
269
|
-
}
|
|
270
|
-
|
|
271
200
|
export default AddPatient;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
@use '@carbon/colors';
|
|
1
2
|
@use '@carbon/layout';
|
|
2
3
|
@use '@carbon/type';
|
|
4
|
+
@use '@openmrs/esm-styleguide/src/vars' as *;
|
|
3
5
|
|
|
4
6
|
.modalContent {
|
|
5
7
|
width: 100%;
|
|
6
|
-
background-color:
|
|
8
|
+
background-color: $ui-02;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
.modalHeader {
|
|
@@ -18,6 +20,16 @@
|
|
|
18
20
|
padding-bottom: 0.875rem;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
.header {
|
|
24
|
+
@include type.type-style('heading-03');
|
|
25
|
+
margin: layout.$spacing-05 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.subheader {
|
|
29
|
+
@include type.type-style('body-01');
|
|
30
|
+
margin: layout.$spacing-05 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
.pagination {
|
|
22
34
|
width: 100%;
|
|
23
35
|
overflow: hidden;
|
|
@@ -30,6 +42,11 @@
|
|
|
30
42
|
position: relative;
|
|
31
43
|
}
|
|
32
44
|
|
|
45
|
+
.search {
|
|
46
|
+
background-color: colors.$white;
|
|
47
|
+
margin-bottom: 0.875rem;
|
|
48
|
+
}
|
|
49
|
+
|
|
33
50
|
.itemsCountDisplay {
|
|
34
51
|
position: absolute;
|
|
35
52
|
top: 0;
|
|
@@ -37,7 +54,7 @@
|
|
|
37
54
|
height: layout.$spacing-09;
|
|
38
55
|
display: flex;
|
|
39
56
|
align-items: center;
|
|
40
|
-
color:
|
|
57
|
+
color: colors.$gray-70;
|
|
41
58
|
}
|
|
42
59
|
|
|
43
60
|
.pagination > div:first-child {
|
|
@@ -47,8 +64,22 @@
|
|
|
47
64
|
|
|
48
65
|
.buttonSet {
|
|
49
66
|
display: flex;
|
|
50
|
-
|
|
51
|
-
|
|
67
|
+
align-items: center;
|
|
68
|
+
justify-content: center;
|
|
69
|
+
width: 100%;
|
|
70
|
+
|
|
71
|
+
.createButton {
|
|
72
|
+
flex: 2;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
div {
|
|
76
|
+
flex: 1;
|
|
77
|
+
display: flex;
|
|
78
|
+
|
|
79
|
+
> button {
|
|
80
|
+
flex: 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
52
83
|
}
|
|
53
84
|
|
|
54
85
|
.productiveHeading03 {
|
|
@@ -58,3 +89,41 @@
|
|
|
58
89
|
.bodyLong01 {
|
|
59
90
|
@include type.type-style('body-01');
|
|
60
91
|
}
|
|
92
|
+
|
|
93
|
+
.tileContainer {
|
|
94
|
+
background-color: $ui-02;
|
|
95
|
+
padding: layout.$spacing-09 0;
|
|
96
|
+
margin-bottom: layout.$spacing-05;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.tile {
|
|
100
|
+
margin: auto;
|
|
101
|
+
width: fit-content;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.tileContent {
|
|
105
|
+
display: flex;
|
|
106
|
+
flex-direction: column;
|
|
107
|
+
align-items: center;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.content {
|
|
111
|
+
@include type.type-style('heading-compact-02');
|
|
112
|
+
color: $text-02;
|
|
113
|
+
margin-bottom: layout.$spacing-03;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.helper {
|
|
117
|
+
@include type.type-style('body-compact-01');
|
|
118
|
+
color: $text-02;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.actionText {
|
|
122
|
+
@include type.type-style('body-01');
|
|
123
|
+
color: $text-02;
|
|
124
|
+
display: flex;
|
|
125
|
+
flex-direction: column;
|
|
126
|
+
align-items: center;
|
|
127
|
+
justify-content: center;
|
|
128
|
+
gap: layout.$spacing-05;
|
|
129
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import { navigate } from '@openmrs/esm-framework';
|
|
5
|
+
import { useAddablePatientLists } from '../api/api-remote';
|
|
6
|
+
import { mockPatient } from '__mocks__';
|
|
7
|
+
import AddPatient from './add-patient.component';
|
|
8
|
+
|
|
9
|
+
const mockNavigate = jest.mocked(navigate);
|
|
10
|
+
const mockUseAddablePatientLists = jest.mocked(useAddablePatientLists);
|
|
11
|
+
const mockCloseModal = jest.fn();
|
|
12
|
+
|
|
13
|
+
jest.mock('../api/api-remote', () => ({
|
|
14
|
+
useAddablePatientLists: jest.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
describe('AddPatient', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
mockUseAddablePatientLists.mockReturnValue({
|
|
20
|
+
data: [
|
|
21
|
+
{ id: 'list1', displayName: 'List 1', addPatient: jest.fn() },
|
|
22
|
+
{ id: 'list2', displayName: 'List 2', addPatient: jest.fn() },
|
|
23
|
+
],
|
|
24
|
+
isLoading: false,
|
|
25
|
+
error: null,
|
|
26
|
+
mutate: jest.fn(),
|
|
27
|
+
isValidating: false,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('renders the Add Patient to List modal', () => {
|
|
32
|
+
render(<AddPatient closeModal={mockCloseModal} patientUuid={mockPatient.uuid} />);
|
|
33
|
+
|
|
34
|
+
expect(screen.getByRole('heading', { name: /add patient to list/i })).toBeInTheDocument();
|
|
35
|
+
expect(screen.getByRole('heading', { name: /search for a list to add this patient to/i })).toBeInTheDocument();
|
|
36
|
+
expect(screen.getByRole('searchbox', { name: /search for a list/i })).toBeInTheDocument();
|
|
37
|
+
expect(screen.getByRole('button', { name: /clear search input/i })).toBeInTheDocument();
|
|
38
|
+
expect(screen.getByRole('button', { name: /create new patient list/i })).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByRole('button', { name: /add to list/i })).toBeInTheDocument();
|
|
40
|
+
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
|
41
|
+
expect(screen.getByRole('checkbox', { name: /list 1/i })).toBeInTheDocument();
|
|
42
|
+
expect(screen.getByRole('checkbox', { name: /list 2/i })).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('allows selecting and deselecting patient lists', async () => {
|
|
46
|
+
const user = userEvent.setup();
|
|
47
|
+
render(<AddPatient closeModal={mockCloseModal} patientUuid={mockPatient.uuid} />);
|
|
48
|
+
|
|
49
|
+
const checkbox1 = screen.getByLabelText('List 1');
|
|
50
|
+
const checkbox2 = screen.getByLabelText('List 2');
|
|
51
|
+
|
|
52
|
+
await user.click(checkbox1);
|
|
53
|
+
expect(checkbox1).toBeChecked();
|
|
54
|
+
|
|
55
|
+
await user.click(checkbox2);
|
|
56
|
+
expect(checkbox2).toBeChecked();
|
|
57
|
+
|
|
58
|
+
await user.click(checkbox1);
|
|
59
|
+
expect(checkbox1).not.toBeChecked();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('filters patient lists based on search input', async () => {
|
|
63
|
+
const user = userEvent.setup();
|
|
64
|
+
render(<AddPatient closeModal={mockCloseModal} patientUuid={mockPatient.uuid} />);
|
|
65
|
+
|
|
66
|
+
const searchInput = screen.getByRole('searchbox', { name: /search for a list/i });
|
|
67
|
+
await user.type(searchInput, 'Bananarama');
|
|
68
|
+
expect(screen.getByText(/no matching lists found/i)).toBeInTheDocument();
|
|
69
|
+
expect(screen.getByText(/try searching for a different list/i)).toBeInTheDocument();
|
|
70
|
+
expect(screen.getAllByRole('button', { name: /create new patient list/i })).toHaveLength(2);
|
|
71
|
+
|
|
72
|
+
await user.clear(searchInput);
|
|
73
|
+
await user.type(searchInput, 'List 1');
|
|
74
|
+
|
|
75
|
+
expect(screen.getByText('List 1')).toBeInTheDocument();
|
|
76
|
+
expect(screen.queryByText('List 2')).not.toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('clicking the "Create new list" button opens the create list form', async () => {
|
|
80
|
+
const user = userEvent.setup();
|
|
81
|
+
render(<AddPatient closeModal={mockCloseModal} patientUuid={mockPatient.uuid} />);
|
|
82
|
+
|
|
83
|
+
const createNewListButton = screen.getByRole('button', { name: /create new patient list/i });
|
|
84
|
+
await user.click(createNewListButton);
|
|
85
|
+
|
|
86
|
+
expect(mockNavigate).toHaveBeenCalledWith({
|
|
87
|
+
to: window.getOpenmrsSpaBase() + 'home/patient-lists?new_cohort=true',
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('clicking the "Cancel" button closes the modal', async () => {
|
|
92
|
+
const user = userEvent.setup();
|
|
93
|
+
render(<AddPatient closeModal={mockCloseModal} patientUuid={mockPatient.uuid} />);
|
|
94
|
+
|
|
95
|
+
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
|
96
|
+
await user.click(cancelButton);
|
|
97
|
+
|
|
98
|
+
expect(mockCloseModal).toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -22,6 +22,7 @@ const AddPatientToPatientListMenuItem: React.FC<AddPastVisitOverflowMenuItemProp
|
|
|
22
22
|
const dispose = showModal('add-patient-to-patient-list-modal', {
|
|
23
23
|
closeModal: () => dispose(),
|
|
24
24
|
patientUuid,
|
|
25
|
+
size: 'sm',
|
|
25
26
|
});
|
|
26
27
|
closeOverflowMenu();
|
|
27
28
|
}, [patientUuid]);
|
|
@@ -26,6 +26,10 @@ describe('AddPatientToPatientListMenuItem', () => {
|
|
|
26
26
|
|
|
27
27
|
await user.click(button);
|
|
28
28
|
|
|
29
|
-
expect(mockShowModal).toHaveBeenCalledWith('add-patient-to-patient-list-modal',
|
|
29
|
+
expect(mockShowModal).toHaveBeenCalledWith('add-patient-to-patient-list-modal', {
|
|
30
|
+
closeModal: expect.any(Function),
|
|
31
|
+
size: 'sm',
|
|
32
|
+
patientUuid: mockPatient.uuid,
|
|
33
|
+
});
|
|
30
34
|
});
|
|
31
35
|
});
|
package/src/api/api-remote.ts
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useTranslation } from 'react-i18next';
|
|
2
|
+
import { type TFunction } from 'i18next';
|
|
3
|
+
import useSWR from 'swr';
|
|
4
|
+
import {
|
|
5
|
+
type LoggedInUser,
|
|
6
|
+
openmrsFetch,
|
|
7
|
+
refetchCurrentUser,
|
|
8
|
+
restBaseUrl,
|
|
9
|
+
fhirBaseUrl,
|
|
10
|
+
getDynamicOfflineDataEntries,
|
|
11
|
+
syncDynamicOfflineData,
|
|
12
|
+
putDynamicOfflineData,
|
|
13
|
+
toOmrsIsoString,
|
|
14
|
+
useConfig,
|
|
15
|
+
} from '@openmrs/esm-framework';
|
|
16
|
+
import { type ConfigSchema } from '../config-schema';
|
|
2
17
|
import {
|
|
3
18
|
type AddPatientData,
|
|
19
|
+
type AddablePatientListViewModel,
|
|
4
20
|
type CohortResponse,
|
|
5
21
|
type NewCohortData,
|
|
6
22
|
type NewCohortDataPayload,
|
|
@@ -9,8 +25,8 @@ import {
|
|
|
9
25
|
type OpenmrsCohortRef,
|
|
10
26
|
type PatientListFilter,
|
|
11
27
|
type PatientListMember,
|
|
12
|
-
PatientListType,
|
|
13
28
|
type PatientListUpdate,
|
|
29
|
+
PatientListType,
|
|
14
30
|
} from './types';
|
|
15
31
|
|
|
16
32
|
export const cohortUrl = `${restBaseUrl}/cohortm`;
|
|
@@ -209,3 +225,75 @@ export async function getPatientListName(patientListUuid: string) {
|
|
|
209
225
|
console.error('Error resolving patient list name: ', error);
|
|
210
226
|
}
|
|
211
227
|
}
|
|
228
|
+
|
|
229
|
+
export async function findRealPatientListsWithoutPatient(
|
|
230
|
+
patientUuid: string,
|
|
231
|
+
myListCohortUUID: string,
|
|
232
|
+
systemListCohortType: string,
|
|
233
|
+
): Promise<Array<AddablePatientListViewModel>> {
|
|
234
|
+
const [allLists, listsIdsOfThisPatient] = await Promise.all([
|
|
235
|
+
getAllPatientLists({}, myListCohortUUID, systemListCohortType),
|
|
236
|
+
getPatientListIdsForPatient(patientUuid),
|
|
237
|
+
]);
|
|
238
|
+
|
|
239
|
+
return allLists.map((list) => ({
|
|
240
|
+
id: list.id,
|
|
241
|
+
displayName: list.display,
|
|
242
|
+
checked: listsIdsOfThisPatient.includes(list.id),
|
|
243
|
+
async addPatient() {
|
|
244
|
+
await addPatientToList({
|
|
245
|
+
cohort: list.id,
|
|
246
|
+
patient: patientUuid,
|
|
247
|
+
startDate: toOmrsIsoString(new Date()),
|
|
248
|
+
});
|
|
249
|
+
},
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function findFakePatientListsWithoutPatient(
|
|
254
|
+
patientUuid: string,
|
|
255
|
+
t: TFunction,
|
|
256
|
+
): Promise<Array<AddablePatientListViewModel>> {
|
|
257
|
+
const offlinePatients = await getDynamicOfflineDataEntries('patient');
|
|
258
|
+
const isPatientOnOfflineList = offlinePatients.some((x) => x.identifier === patientUuid);
|
|
259
|
+
return isPatientOnOfflineList
|
|
260
|
+
? []
|
|
261
|
+
: [
|
|
262
|
+
{
|
|
263
|
+
id: 'fake-offline-patient-list',
|
|
264
|
+
displayName: t('offlinePatients', 'Offline patients'),
|
|
265
|
+
async addPatient() {
|
|
266
|
+
await putDynamicOfflineData('patient', patientUuid);
|
|
267
|
+
await syncDynamicOfflineData('patient', patientUuid);
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// This entire model is a little bit special since it not only displays the "real" patient lists (i.e. data from
|
|
274
|
+
// the cohorts/backend), but also a fake patient list which doesn't really exist in the backend:
|
|
275
|
+
// The offline patient list.
|
|
276
|
+
// When a patient is added to the offline list, that patient should become available offline, i.e.
|
|
277
|
+
// a dynamic offline data entry must be created.
|
|
278
|
+
// This is why the following abstracts away the differences between the real and the fake patient lists.
|
|
279
|
+
// The component doesn't really care about which is which - the only thing that matters is that the
|
|
280
|
+
// data can be fetched and that there is an "add patient" function.
|
|
281
|
+
|
|
282
|
+
export function useAddablePatientLists(patientUuid: string) {
|
|
283
|
+
const { t } = useTranslation();
|
|
284
|
+
const config = useConfig<ConfigSchema>();
|
|
285
|
+
return useSWR(['addablePatientLists', patientUuid], async () => {
|
|
286
|
+
// Using Promise.allSettled instead of Promise.all here because some distros might not have the
|
|
287
|
+
// cohort module installed, leading to the real patient list call failing.
|
|
288
|
+
// In that case we still want to show fake lists and *not* error out here.
|
|
289
|
+
const [fakeLists, realLists] = await Promise.allSettled([
|
|
290
|
+
findFakePatientListsWithoutPatient(patientUuid, t),
|
|
291
|
+
findRealPatientListsWithoutPatient(patientUuid, config.myListCohortTypeUUID, config.systemListCohortTypeUUID),
|
|
292
|
+
]);
|
|
293
|
+
|
|
294
|
+
return [
|
|
295
|
+
...(fakeLists.status === 'fulfilled' ? fakeLists.value : []),
|
|
296
|
+
...(realLists.status === 'fulfilled' ? realLists.value : []),
|
|
297
|
+
];
|
|
298
|
+
});
|
|
299
|
+
}
|
package/src/api/types.ts
CHANGED
|
@@ -7,6 +7,13 @@ export enum PatientListType {
|
|
|
7
7
|
ALL = 'All',
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export interface AddablePatientListViewModel {
|
|
11
|
+
addPatient(): Promise<void>;
|
|
12
|
+
displayName: string;
|
|
13
|
+
checked?: boolean;
|
|
14
|
+
id: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
10
17
|
export interface PatientList {
|
|
11
18
|
id: string;
|
|
12
19
|
display: string;
|