@kenyaemr/esm-patient-list-management-app 7.0.2-pre.65
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 +41 -0
- package/dist/130.js +2 -0
- package/dist/130.js.LICENSE.txt +3 -0
- package/dist/130.js.map +1 -0
- package/dist/139.js +1 -0
- package/dist/139.js.map +1 -0
- package/dist/255.js +2 -0
- package/dist/255.js.LICENSE.txt +9 -0
- package/dist/255.js.map +1 -0
- package/dist/271.js +1 -0
- package/dist/319.js +1 -0
- package/dist/382.js +1 -0
- package/dist/382.js.map +1 -0
- package/dist/443.js +1 -0
- package/dist/443.js.map +1 -0
- package/dist/460.js +1 -0
- package/dist/548.js +1 -0
- package/dist/548.js.map +1 -0
- package/dist/574.js +1 -0
- package/dist/591.js +2 -0
- package/dist/591.js.LICENSE.txt +32 -0
- package/dist/591.js.map +1 -0
- package/dist/635.js +1 -0
- package/dist/635.js.map +1 -0
- package/dist/644.js +1 -0
- package/dist/729.js +1 -0
- package/dist/729.js.map +1 -0
- package/dist/757.js +1 -0
- package/dist/784.js +2 -0
- package/dist/784.js.LICENSE.txt +9 -0
- package/dist/784.js.map +1 -0
- package/dist/788.js +1 -0
- package/dist/807.js +1 -0
- package/dist/833.js +1 -0
- package/dist/930.js +2 -0
- package/dist/930.js.LICENSE.txt +35 -0
- package/dist/930.js.map +1 -0
- package/dist/kenyaemr-esm-patient-list-management-app.js +1 -0
- package/dist/kenyaemr-esm-patient-list-management-app.js.buildmanifest.json +580 -0
- package/dist/kenyaemr-esm-patient-list-management-app.js.map +1 -0
- package/dist/main.js +2 -0
- package/dist/main.js.LICENSE.txt +45 -0
- package/dist/main.js.map +1 -0
- package/dist/routes.json +1 -0
- package/jest.config.js +3 -0
- package/package.json +56 -0
- package/src/add-patient/add-patient.component.tsx +271 -0
- package/src/add-patient/add-patient.scss +51 -0
- package/src/add-patient-to-patient-list-menu-item.component.tsx +48 -0
- package/src/add-patient-to-patient-list-menu-item.test.tsx +33 -0
- package/src/api/api-remote.ts +211 -0
- package/src/api/hooks.ts +150 -0
- package/src/api/types.ts +102 -0
- package/src/config-schema.ts +25 -0
- package/src/constants.ts +5 -0
- package/src/create-edit-patient-list/create-edit-list.component.tsx +170 -0
- package/src/create-edit-patient-list/create-edit-patient-list.scss +31 -0
- package/src/createDashboardLink.component.tsx +40 -0
- package/src/dashboard.meta.ts +5 -0
- package/src/declarations.d.ts +5 -0
- package/src/empty-state/empty-data-illustration.component.tsx +42 -0
- package/src/empty-state/empty-state.component.tsx +41 -0
- package/src/empty-state/empty-state.scss +24 -0
- package/src/error-state/error-state.component.tsx +35 -0
- package/src/error-state/error-state.scss +50 -0
- package/src/header/header.component.tsx +51 -0
- package/src/header/header.scss +52 -0
- package/src/illo.component.tsx +25 -0
- package/src/index.ts +41 -0
- package/src/list-details/list-details.component.tsx +201 -0
- package/src/list-details/list-details.scss +47 -0
- package/src/list-details/list-details.test.tsx +112 -0
- package/src/list-details/patient-list-detail.test.tsx +105 -0
- package/src/list-details-table/list-details-table.component.tsx +402 -0
- package/src/list-details-table/list-details-table.scss +143 -0
- package/src/list-details-table/list-details-table.test.tsx +94 -0
- package/src/lists-dashboard/lists-dashboard.component.tsx +104 -0
- package/src/lists-dashboard/lists-dashboard.scss +110 -0
- package/src/lists-dashboard/lists-dashboard.test.tsx +129 -0
- package/src/lists-table/custom-pagination.component.tsx +43 -0
- package/src/lists-table/custom-pagination.scss +67 -0
- package/src/lists-table/lists-table.component.tsx +317 -0
- package/src/lists-table/lists-table.scss +100 -0
- package/src/lists-table/lists-table.test.tsx +189 -0
- package/src/lists-table/use-pagination-info.component.tsx +35 -0
- package/src/offline.ts +31 -0
- package/src/overlay.component.tsx +49 -0
- package/src/overlay.scss +89 -0
- package/src/overlay.test.tsx +60 -0
- package/src/root.component.tsx +19 -0
- package/src/routes.json +36 -0
- package/src/style.scss +46 -0
- package/translations/am.json +84 -0
- package/translations/ar.json +84 -0
- package/translations/en.json +84 -0
- package/translations/es.json +84 -0
- package/translations/fr.json +84 -0
- package/translations/he.json +84 -0
- package/translations/km.json +84 -0
- package/translations/zh.json +84 -0
- package/translations/zh_CN.json +84 -0
- package/tsconfig.json +5 -0
- package/webpack.config.js +1 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import React, { type CSSProperties, useCallback, useId, useMemo, useState, useEffect } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import fuzzy from 'fuzzy';
|
|
4
|
+
import orderBy from 'lodash-es/orderBy';
|
|
5
|
+
import type { TFunction } from 'i18next';
|
|
6
|
+
import {
|
|
7
|
+
Button,
|
|
8
|
+
DataTable,
|
|
9
|
+
DataTableSkeleton,
|
|
10
|
+
InlineLoading,
|
|
11
|
+
Layer,
|
|
12
|
+
Search,
|
|
13
|
+
Table,
|
|
14
|
+
TableBody,
|
|
15
|
+
TableCell,
|
|
16
|
+
TableContainer,
|
|
17
|
+
TableHead,
|
|
18
|
+
TableHeader,
|
|
19
|
+
TableRow,
|
|
20
|
+
Tile,
|
|
21
|
+
} from '@carbon/react';
|
|
22
|
+
import { Star, StarFilled } from '@carbon/react/icons';
|
|
23
|
+
import {
|
|
24
|
+
ConfigurableLink,
|
|
25
|
+
isDesktop,
|
|
26
|
+
showSnackbar,
|
|
27
|
+
useConfig,
|
|
28
|
+
useLayoutType,
|
|
29
|
+
usePagination,
|
|
30
|
+
useSession,
|
|
31
|
+
} from '@openmrs/esm-framework';
|
|
32
|
+
import type { ConfigSchema } from '../config-schema';
|
|
33
|
+
import type { PatientList } from '../api/types';
|
|
34
|
+
import { starPatientList } from '../api/api-remote';
|
|
35
|
+
import { CustomPagination } from './custom-pagination.component';
|
|
36
|
+
import { ErrorState } from '../error-state/error-state.component';
|
|
37
|
+
import EmptyState from '../empty-state/empty-state.component';
|
|
38
|
+
import styles from './lists-table.scss';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* FIXME Temporarily moved here
|
|
42
|
+
*/
|
|
43
|
+
interface DataTableHeader {
|
|
44
|
+
key: string;
|
|
45
|
+
header: React.ReactNode;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface PatientListTableProps {
|
|
49
|
+
error?: Error | null;
|
|
50
|
+
handleCreate?: () => void;
|
|
51
|
+
headers?: Array<DataTableHeader>;
|
|
52
|
+
isLoading?: boolean;
|
|
53
|
+
isValidating?: boolean;
|
|
54
|
+
listType: string;
|
|
55
|
+
patientLists: Array<PatientList>;
|
|
56
|
+
refetch?(): void;
|
|
57
|
+
style?: CSSProperties;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const ListsTable: React.FC<PatientListTableProps> = ({
|
|
61
|
+
error,
|
|
62
|
+
handleCreate,
|
|
63
|
+
headers,
|
|
64
|
+
isLoading = false,
|
|
65
|
+
isValidating,
|
|
66
|
+
listType,
|
|
67
|
+
patientLists = [],
|
|
68
|
+
style,
|
|
69
|
+
}) => {
|
|
70
|
+
const { t } = useTranslation();
|
|
71
|
+
const id = useId();
|
|
72
|
+
const layout = useLayoutType();
|
|
73
|
+
const config: ConfigSchema = useConfig();
|
|
74
|
+
const pageSize = config.patientListsToShow ?? 10;
|
|
75
|
+
const [sortParams, setSortParams] = useState({ key: '', order: 'none' });
|
|
76
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
77
|
+
const responsiveSize = layout === 'tablet' ? 'lg' : 'sm';
|
|
78
|
+
|
|
79
|
+
const { toggleStarredList, starredLists } = useStarredLists();
|
|
80
|
+
|
|
81
|
+
function customSortRow(listA, listB, { sortDirection, sortStates, ...props }) {
|
|
82
|
+
const { key } = props;
|
|
83
|
+
setSortParams({ key, order: sortDirection });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const filteredLists: Array<PatientList> = useMemo(() => {
|
|
87
|
+
if (!searchTerm) {
|
|
88
|
+
return patientLists;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return fuzzy
|
|
92
|
+
.filter(searchTerm, patientLists, { extract: (list) => `${list.display} ${list.type}` })
|
|
93
|
+
.sort((r1, r2) => r1.score - r2.score)
|
|
94
|
+
.map((result) => result.original);
|
|
95
|
+
}, [patientLists, searchTerm]);
|
|
96
|
+
|
|
97
|
+
const { key, order } = sortParams;
|
|
98
|
+
const sortedData =
|
|
99
|
+
order === 'DESC' ? orderBy(filteredLists, [key], ['desc']) : orderBy(filteredLists, [key], ['asc']);
|
|
100
|
+
|
|
101
|
+
const { paginated, goTo, results, currentPage } = usePagination(sortedData, pageSize);
|
|
102
|
+
|
|
103
|
+
const tableRows = useMemo(
|
|
104
|
+
() =>
|
|
105
|
+
results.map((list) => ({
|
|
106
|
+
id: list.id,
|
|
107
|
+
display: list.display,
|
|
108
|
+
description: list.description,
|
|
109
|
+
type: list.type,
|
|
110
|
+
size: list.size,
|
|
111
|
+
})) ?? [],
|
|
112
|
+
[results],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (isLoading) {
|
|
116
|
+
return (
|
|
117
|
+
<DataTableSkeleton
|
|
118
|
+
columnCount={headers.length}
|
|
119
|
+
compact={isDesktop(layout)}
|
|
120
|
+
role="progressbar"
|
|
121
|
+
rowCount={pageSize}
|
|
122
|
+
showHeader={false}
|
|
123
|
+
showToolbar={false}
|
|
124
|
+
style={{ ...style, backgroundColor: 'transparent', padding: '0rem' }}
|
|
125
|
+
zebra
|
|
126
|
+
/>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (error) {
|
|
131
|
+
return <ErrorState error={error} headerTitle={t('patientLists', 'Patient lists')} />;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (patientLists.length === 0) {
|
|
135
|
+
return <EmptyState launchForm={handleCreate} listType={listType} />;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<>
|
|
140
|
+
<div id="tableToolBar" className={styles.searchContainer}>
|
|
141
|
+
<div>{isValidating && <InlineLoading />}</div>
|
|
142
|
+
<Layer>
|
|
143
|
+
<Search
|
|
144
|
+
className={styles.searchbox}
|
|
145
|
+
id={`${id}-search`}
|
|
146
|
+
labelText=""
|
|
147
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)}
|
|
148
|
+
placeholder={t('searchThisList', 'Search this list')}
|
|
149
|
+
size={responsiveSize}
|
|
150
|
+
value={searchTerm}
|
|
151
|
+
/>
|
|
152
|
+
</Layer>
|
|
153
|
+
</div>
|
|
154
|
+
<DataTable rows={tableRows} headers={headers} size={responsiveSize} sortRow={customSortRow}>
|
|
155
|
+
{({ rows, headers, getTableProps, getHeaderProps, getRowProps, getTableContainerProps }) => (
|
|
156
|
+
<TableContainer {...getTableContainerProps()} className={styles.tableContainer}>
|
|
157
|
+
<Table
|
|
158
|
+
{...getTableProps()}
|
|
159
|
+
className={styles.table}
|
|
160
|
+
data-testid="patientListsTable"
|
|
161
|
+
isSortable
|
|
162
|
+
useZebraStyles>
|
|
163
|
+
<TableHead>
|
|
164
|
+
<TableRow>
|
|
165
|
+
{headers.map((header) => (
|
|
166
|
+
<TableHeader
|
|
167
|
+
className={isDesktop(layout) ? styles.desktopHeader : styles.tabletHeader}
|
|
168
|
+
key={header.key}
|
|
169
|
+
{...getHeaderProps({ header })}
|
|
170
|
+
isSortable>
|
|
171
|
+
{header.header}
|
|
172
|
+
</TableHeader>
|
|
173
|
+
))}
|
|
174
|
+
</TableRow>
|
|
175
|
+
</TableHead>
|
|
176
|
+
<TableBody className={styles.tableBody}>
|
|
177
|
+
{rows.map((row) => {
|
|
178
|
+
const currentList = patientLists?.find((list) => list?.id === row.id);
|
|
179
|
+
const listDetailsPageUrl = '${openmrsSpaBase}/home/patient-lists/${listUuid}';
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<TableRow
|
|
183
|
+
{...getRowProps({ row })}
|
|
184
|
+
className={isDesktop(layout) ? styles.desktopRow : styles.tabletRow}
|
|
185
|
+
key={row.id}>
|
|
186
|
+
<TableCell>
|
|
187
|
+
<ConfigurableLink
|
|
188
|
+
className={styles.link}
|
|
189
|
+
to={listDetailsPageUrl}
|
|
190
|
+
templateParams={{ listUuid: row.id }}>
|
|
191
|
+
{currentList?.display ?? ''}
|
|
192
|
+
</ConfigurableLink>
|
|
193
|
+
</TableCell>
|
|
194
|
+
<TableCell>{currentList?.type ?? ''}</TableCell>
|
|
195
|
+
<TableCell>{currentList?.size ?? ''}</TableCell>
|
|
196
|
+
<PatientListStarIcon
|
|
197
|
+
cohortUuid={row.id}
|
|
198
|
+
isStarred={starredLists.includes(row.id)}
|
|
199
|
+
toggleStarredList={toggleStarredList}
|
|
200
|
+
t={t}
|
|
201
|
+
/>
|
|
202
|
+
</TableRow>
|
|
203
|
+
);
|
|
204
|
+
})}
|
|
205
|
+
</TableBody>
|
|
206
|
+
</Table>
|
|
207
|
+
</TableContainer>
|
|
208
|
+
)}
|
|
209
|
+
</DataTable>
|
|
210
|
+
{filteredLists?.length === 0 && (
|
|
211
|
+
<div className={styles.filterEmptyState}>
|
|
212
|
+
<Layer level={0}>
|
|
213
|
+
<Tile className={styles.filterEmptyStateTile}>
|
|
214
|
+
<p className={styles.filterEmptyStateContent}>{t('noMatchingLists', 'No matching lists to display')}</p>
|
|
215
|
+
<p className={styles.filterEmptyStateHelper}>{t('checkFilters', 'Check the filters above')}</p>
|
|
216
|
+
</Tile>
|
|
217
|
+
</Layer>
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
{paginated && (
|
|
221
|
+
<CustomPagination
|
|
222
|
+
currentItems={results.length}
|
|
223
|
+
totalItems={filteredLists.length}
|
|
224
|
+
onPageNumberChange={({ page }) => {
|
|
225
|
+
goTo(page);
|
|
226
|
+
}}
|
|
227
|
+
pageNumber={currentPage}
|
|
228
|
+
pageSize={pageSize}
|
|
229
|
+
/>
|
|
230
|
+
)}
|
|
231
|
+
</>
|
|
232
|
+
);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
interface PatientListStarIconProps {
|
|
236
|
+
cohortUuid: string;
|
|
237
|
+
isStarred: boolean;
|
|
238
|
+
toggleStarredList: (cohortUuid: string, starList) => void;
|
|
239
|
+
t: TFunction;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const PatientListStarIcon: React.FC<PatientListStarIconProps> = ({ cohortUuid, isStarred, toggleStarredList, t }) => {
|
|
243
|
+
const isTablet = useLayoutType() === 'tablet';
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
<TableCell className={`cds--table-column-menu ${styles.starButton}`} key={cohortUuid} style={{ cursor: 'pointer' }}>
|
|
247
|
+
<Button
|
|
248
|
+
iconDescription={isStarred ? t('unstarList', 'Unstar list') : t('starList', 'Star list')}
|
|
249
|
+
size={isTablet ? 'lg' : 'sm'}
|
|
250
|
+
kind="ghost"
|
|
251
|
+
hasIconOnly
|
|
252
|
+
renderIcon={isStarred ? StarFilled : Star}
|
|
253
|
+
tooltipPosition="left"
|
|
254
|
+
enterDelayMs={500}
|
|
255
|
+
onClick={() => toggleStarredList(cohortUuid, !isStarred)}
|
|
256
|
+
/>
|
|
257
|
+
</TableCell>
|
|
258
|
+
);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
function useStarredLists() {
|
|
262
|
+
const { t } = useTranslation();
|
|
263
|
+
const [starredLists, setStarredLists] = useState([]);
|
|
264
|
+
const [starhandleTimeout, setStarHandleTimeout] = useState(null);
|
|
265
|
+
const { user: currentUser } = useSession();
|
|
266
|
+
|
|
267
|
+
const setInitialStarredLists = useCallback(() => {
|
|
268
|
+
const starredPatientLists = currentUser?.userProperties?.starredPatientLists ?? '';
|
|
269
|
+
setStarredLists(starredPatientLists.split(','));
|
|
270
|
+
}, [currentUser?.userProperties?.starredPatientLists, setStarredLists]);
|
|
271
|
+
|
|
272
|
+
const updateUserProperties = (newStarredLists: Array<string>) => {
|
|
273
|
+
const starredPatientLists = newStarredLists.join(',');
|
|
274
|
+
const userProperties = { ...(currentUser?.userProperties ?? {}), starredPatientLists };
|
|
275
|
+
|
|
276
|
+
starPatientList(currentUser?.uuid, userProperties).catch(() => {
|
|
277
|
+
setInitialStarredLists();
|
|
278
|
+
showSnackbar({
|
|
279
|
+
subtitle: t('starringPatientListFailed', 'Marking patient lists starred / unstarred failed'),
|
|
280
|
+
kind: 'error',
|
|
281
|
+
title: 'Failed to update patient lists',
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Handles toggling the starred list
|
|
288
|
+
* It uses a timeout to store all the changes made by the user
|
|
289
|
+
* and pass the changes in a single request
|
|
290
|
+
* @param cohortUuid
|
|
291
|
+
* @param starPatientList
|
|
292
|
+
*/
|
|
293
|
+
const toggleStarredList = useCallback(
|
|
294
|
+
(cohortUuid, starPatientList) => {
|
|
295
|
+
const newStarredLists = starPatientList
|
|
296
|
+
? [...starredLists, cohortUuid]
|
|
297
|
+
: starredLists.filter((uuid) => uuid !== cohortUuid);
|
|
298
|
+
setStarredLists(newStarredLists);
|
|
299
|
+
if (starhandleTimeout) {
|
|
300
|
+
clearTimeout(starhandleTimeout);
|
|
301
|
+
}
|
|
302
|
+
const timeout = setTimeout(() => updateUserProperties(newStarredLists), 1500);
|
|
303
|
+
setStarHandleTimeout(timeout);
|
|
304
|
+
},
|
|
305
|
+
[starredLists, starhandleTimeout, setStarredLists, setStarHandleTimeout, updateUserProperties],
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
if (currentUser?.userProperties?.starredPatientLists) {
|
|
310
|
+
setInitialStarredLists();
|
|
311
|
+
}
|
|
312
|
+
}, [currentUser?.userProperties?.starredPatientLists, setInitialStarredLists]);
|
|
313
|
+
|
|
314
|
+
return { toggleStarredList, starredLists };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export default ListsTable;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
@use '@carbon/colors';
|
|
2
|
+
@use '@carbon/styles/scss/spacing';
|
|
3
|
+
@use '@carbon/styles/scss/type';
|
|
4
|
+
@import '../style.scss';
|
|
5
|
+
|
|
6
|
+
.table {
|
|
7
|
+
tr {
|
|
8
|
+
&:last-of-type {
|
|
9
|
+
td {
|
|
10
|
+
border-bottom: none;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
td {
|
|
16
|
+
padding-top: 0;
|
|
17
|
+
padding-bottom: 0;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.desktopRow,
|
|
22
|
+
.desktopHeader {
|
|
23
|
+
height: spacing.$spacing-07 !important;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.tabletRow,
|
|
27
|
+
.tabletHeader {
|
|
28
|
+
height: spacing.$spacing-09 !important;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.tableBody {
|
|
32
|
+
background-color: $ui-01;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.tableCell,
|
|
36
|
+
.interactiveText01 {
|
|
37
|
+
color: $interactive-01;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.tableContainer {
|
|
41
|
+
padding: 0;
|
|
42
|
+
|
|
43
|
+
:global(.cds--data-table-content) {
|
|
44
|
+
border: 1px solid colors.$gray-20;
|
|
45
|
+
border-bottom: none;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.link {
|
|
50
|
+
text-decoration: none;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.tile {
|
|
54
|
+
text-align: center;
|
|
55
|
+
border-bottom: 1px solid $ui-03;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.filterEmptyState {
|
|
59
|
+
display: flex;
|
|
60
|
+
justify-content: center;
|
|
61
|
+
align-items: center;
|
|
62
|
+
padding: spacing.$spacing-09;
|
|
63
|
+
text-align: center;
|
|
64
|
+
border: 1px solid $ui-03;
|
|
65
|
+
background-color: white;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.filterEmptyStateTile {
|
|
69
|
+
margin: auto;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.filterEmptyStateContent {
|
|
73
|
+
@include type.type-style('heading-compact-02');
|
|
74
|
+
color: $text-02;
|
|
75
|
+
margin-bottom: 0.5rem;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.filterEmptyStateHelper {
|
|
79
|
+
@include type.type-style('body-compact-01');
|
|
80
|
+
color: $text-02;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Patient List Table
|
|
84
|
+
.searchContainer {
|
|
85
|
+
display: grid;
|
|
86
|
+
grid-template-columns: 1fr 1fr;
|
|
87
|
+
align-items: center;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.searchContainer > div {
|
|
91
|
+
align-self: center;
|
|
92
|
+
justify-self: flex-end;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.searchbox {
|
|
96
|
+
width: 100%;
|
|
97
|
+
max-width: 16rem;
|
|
98
|
+
background-color: $ui-02;
|
|
99
|
+
border-bottom-color: $ui-03;
|
|
100
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { render, screen, within } from '@testing-library/react';
|
|
4
|
+
import { useSession } from '@openmrs/esm-framework';
|
|
5
|
+
import { mockSession } from '__mocks__';
|
|
6
|
+
import type { PatientList } from '../api/types';
|
|
7
|
+
import ListsTable from './lists-table.component';
|
|
8
|
+
|
|
9
|
+
const mockedUseSession = jest.mocked(useSession);
|
|
10
|
+
|
|
11
|
+
jest.mock('@openmrs/esm-framework', () => ({
|
|
12
|
+
...jest.requireActual('@openmrs/esm-framework'),
|
|
13
|
+
useConfig: jest.fn(() => ({
|
|
14
|
+
patientListsToShow: 10,
|
|
15
|
+
})),
|
|
16
|
+
isDesktop: jest.fn(() => true),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const tableHeaders = [
|
|
20
|
+
{ header: 'List name', key: '1' },
|
|
21
|
+
{ header: 'List type', key: '2' },
|
|
22
|
+
{ header: 'No. of patients', key: '3' },
|
|
23
|
+
{ header: 'Starred', key: '4' },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const patientLists: Array<PatientList> = [
|
|
27
|
+
{
|
|
28
|
+
id: '3279e187-2d16-4222-a522-54fa9112d567',
|
|
29
|
+
display: 'COBALT Cohort',
|
|
30
|
+
description:
|
|
31
|
+
"Children's Obstructive Lung Disease, Bronchiectasis and Antibiotic Tolerance Study (investigates novel antibiotic treatment for persistent childhood lung infections)",
|
|
32
|
+
type: 'My List',
|
|
33
|
+
size: 200,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'f1b2ca00-6742-490d-9062-5025644c7632',
|
|
37
|
+
display: 'GENESIS Cohort',
|
|
38
|
+
description:
|
|
39
|
+
'Genomic Evaluation of Neonatal Early Sepsis in Infants - Stratified for Risk Factors (examines genetic factors influencing sepsis risk in newborns).',
|
|
40
|
+
type: 'My List',
|
|
41
|
+
size: 300,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: '9c5e8677-6747-4315-84c7-a20f30d795e2',
|
|
45
|
+
display: 'VIGOR Cohort',
|
|
46
|
+
description:
|
|
47
|
+
'Vascular Imaging and Genomics of Onset and Recovery in Stroke (analyzes cardiovascular and genetic markers predictive of stroke outcomes)',
|
|
48
|
+
type: 'My List',
|
|
49
|
+
size: 500,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'be10d553-b183-4647-9be6-160d1246de8a',
|
|
53
|
+
display: 'EQUITY Cohort',
|
|
54
|
+
description:
|
|
55
|
+
'Equitable Quality in Cancer Treatment for Underserved Young Adults (assesses healthcare disparities in cancer treatment for young adults from disadvantaged backgrounds)',
|
|
56
|
+
type: 'My List',
|
|
57
|
+
size: 100,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: '72a84c22-2425-4501-95b7-820b793602f3',
|
|
61
|
+
display: 'MINDSCAPE Cohort',
|
|
62
|
+
description:
|
|
63
|
+
'Mental Illness and Neuroimaging Study for Personalized Assessment and Care Evaluation (develops personalized treatment plans for mental illness based on brain imaging and individual factors)',
|
|
64
|
+
type: 'My List',
|
|
65
|
+
size: 250,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: '06ca3df6-92d6-4401-8f06-4f094b004425',
|
|
69
|
+
display: 'MEND Cohort',
|
|
70
|
+
description:
|
|
71
|
+
'Mediterranean Diet, Exercise, and Nutrition for Diabetes (evaluates the combined effects of dietary and lifestyle changes on diabetes management).',
|
|
72
|
+
type: 'My List',
|
|
73
|
+
size: 150,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
describe('ListsTable', () => {
|
|
78
|
+
beforeEach(() => mockedUseSession.mockReturnValue(mockSession.data));
|
|
79
|
+
|
|
80
|
+
it('renders a loading state when patient list data is getting fetched', () => {
|
|
81
|
+
render(<ListsTable error={null} headers={tableHeaders} isLoading listType={'My lists'} patientLists={[]} />);
|
|
82
|
+
|
|
83
|
+
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('renders an error state when there is a problem loading patient list data', () => {
|
|
87
|
+
const error = {
|
|
88
|
+
message: 'You are not logged in',
|
|
89
|
+
response: {
|
|
90
|
+
status: 401,
|
|
91
|
+
statusText: 'Unauthorized',
|
|
92
|
+
},
|
|
93
|
+
} as unknown as Error;
|
|
94
|
+
|
|
95
|
+
render(
|
|
96
|
+
<ListsTable
|
|
97
|
+
error={error}
|
|
98
|
+
headers={tableHeaders}
|
|
99
|
+
isLoading={false}
|
|
100
|
+
listType={''}
|
|
101
|
+
patientLists={[]}
|
|
102
|
+
refetch={jest.fn()}
|
|
103
|
+
/>,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(screen.getByText(/401:\s*unauthorized/i)).toBeInTheDocument();
|
|
107
|
+
expect(screen.getByText(/sorry, there was a problem displaying this information/i)).toBeInTheDocument();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('renders an empty state when there is no patient list data to display', () => {
|
|
111
|
+
render(<ListsTable patientLists={[]} listType={''} />);
|
|
112
|
+
|
|
113
|
+
expect(screen.getByTitle(/empty state illustration/i)).toBeInTheDocument();
|
|
114
|
+
expect(screen.getByText(/there are no patient lists to display/i)).toBeInTheDocument();
|
|
115
|
+
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('renders the available patient lists in a datatable', () => {
|
|
119
|
+
const pageSize = 5;
|
|
120
|
+
|
|
121
|
+
render(<ListsTable patientLists={patientLists} listType={''} headers={tableHeaders} isLoading={false} />);
|
|
122
|
+
|
|
123
|
+
const columnHeaders = [/List name/, /List type/, /No. of patients/, /Starred/];
|
|
124
|
+
|
|
125
|
+
columnHeaders.forEach((header) => {
|
|
126
|
+
expect(screen.getByRole('columnheader', { name: new RegExp(header, 'i') })).toBeInTheDocument();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const searchInput = screen.getByRole('searchbox');
|
|
130
|
+
expect(searchInput).toBeInTheDocument();
|
|
131
|
+
|
|
132
|
+
patientLists.slice(0, pageSize).forEach((list) => {
|
|
133
|
+
expect(
|
|
134
|
+
screen.getByRole('row', { name: `${list.display} ${list.type} ${list.size} Star list` }),
|
|
135
|
+
).toBeInTheDocument();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('searches for patient lists by the list name or type', async () => {
|
|
140
|
+
const user = userEvent.setup();
|
|
141
|
+
const pageSize = 5;
|
|
142
|
+
|
|
143
|
+
render(<ListsTable patientLists={patientLists} listType={''} headers={tableHeaders} isLoading={false} />);
|
|
144
|
+
|
|
145
|
+
patientLists.slice(0, pageSize).forEach((list) => {
|
|
146
|
+
expect(
|
|
147
|
+
screen.getByRole('row', { name: `${list.display} ${list.type} ${list.size} Star list` }),
|
|
148
|
+
).toBeInTheDocument();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const searchInput = screen.getByRole('searchbox');
|
|
152
|
+
expect(searchInput).toBeInTheDocument();
|
|
153
|
+
|
|
154
|
+
// Search for an existing list
|
|
155
|
+
await user.type(searchInput, 'cobalt');
|
|
156
|
+
|
|
157
|
+
expect(screen.getByRole('row', { name: /cobalt cohort my list 200 star list/i })).toBeInTheDocument();
|
|
158
|
+
expect(screen.getAllByRole('row').length).toBe(2);
|
|
159
|
+
|
|
160
|
+
// Search for a list that is not in the first page of results
|
|
161
|
+
await user.clear(searchInput);
|
|
162
|
+
await user.type(searchInput, 'mend');
|
|
163
|
+
|
|
164
|
+
expect(screen.getByRole('row', { name: /mend cohort my list 150 star list/i })).toBeInTheDocument();
|
|
165
|
+
|
|
166
|
+
// Search for a list that does not exist
|
|
167
|
+
await user.clear(searchInput);
|
|
168
|
+
await user.type(searchInput, 'apollo-soyuz');
|
|
169
|
+
expect(screen.getByText(/no matching lists to display/i)).toBeInTheDocument();
|
|
170
|
+
expect(screen.getByText(/check the filters above/i)).toBeInTheDocument();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('clicking the "Star list" button toggles the starred status of a patient list', async () => {
|
|
174
|
+
const user = userEvent.setup();
|
|
175
|
+
const pageSize = 5;
|
|
176
|
+
|
|
177
|
+
render(<ListsTable patientLists={patientLists} listType={''} headers={tableHeaders} isLoading={false} />);
|
|
178
|
+
|
|
179
|
+
const cobaltCohortRow = screen.getByRole('row', { name: /cobalt cohort my list 200/i });
|
|
180
|
+
const starListButton = within(cobaltCohortRow).queryByRole('button', { name: /^star list$/i });
|
|
181
|
+
const unstarListButton = within(cobaltCohortRow).queryByRole('button', { name: /^unstar list$/i });
|
|
182
|
+
|
|
183
|
+
expect(unstarListButton).not.toBeInTheDocument();
|
|
184
|
+
expect(starListButton).toBeInTheDocument();
|
|
185
|
+
|
|
186
|
+
await user.click(starListButton);
|
|
187
|
+
await screen.findByRole('button', { name: /^unstar list$/i });
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
|
|
4
|
+
export function usePaginationInfo(pageSize: number, totalItems: number, pageNumber: number, currentItems: number) {
|
|
5
|
+
const { t } = useTranslation();
|
|
6
|
+
|
|
7
|
+
const pageSizes = useMemo(() => {
|
|
8
|
+
let numberOfPages = Math.ceil(totalItems / pageSize);
|
|
9
|
+
if (isNaN(numberOfPages)) {
|
|
10
|
+
numberOfPages = 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return [...Array(numberOfPages).keys()].map((x) => {
|
|
14
|
+
return (x + 1) * pageSize;
|
|
15
|
+
});
|
|
16
|
+
}, [pageSize, totalItems]);
|
|
17
|
+
|
|
18
|
+
const numberOfItemsDisplayed = useMemo(() => {
|
|
19
|
+
if (pageSize > totalItems) {
|
|
20
|
+
return `${totalItems} / ${totalItems}`;
|
|
21
|
+
} else if (pageSize * pageNumber > totalItems) {
|
|
22
|
+
return `${pageSize * (pageNumber - 1) + currentItems} / ${totalItems}`;
|
|
23
|
+
} else {
|
|
24
|
+
return `${pageSize * pageNumber} / ${totalItems}`;
|
|
25
|
+
}
|
|
26
|
+
}, [pageSize, totalItems, pageNumber, currentItems]);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
pageSizes,
|
|
30
|
+
itemsDisplayed: t('itemsDisplayed', '{{numberOfItemsDisplayed}} items', {
|
|
31
|
+
numberOfItemsDisplayed,
|
|
32
|
+
interpolation: { escapeValue: false },
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
}
|
package/src/offline.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fetchCurrentPatient,
|
|
3
|
+
fhirBaseUrl,
|
|
4
|
+
makeUrl,
|
|
5
|
+
messageOmrsServiceWorker,
|
|
6
|
+
setupDynamicOfflineDataHandler,
|
|
7
|
+
} from '@openmrs/esm-framework';
|
|
8
|
+
import { cacheForOfflineHeaders } from './constants';
|
|
9
|
+
|
|
10
|
+
export function setupOffline() {
|
|
11
|
+
setupDynamicOfflineDataHandler({
|
|
12
|
+
id: 'esm-patient-list-management-app:patient',
|
|
13
|
+
type: 'patient',
|
|
14
|
+
displayName: 'Patient list',
|
|
15
|
+
async isSynced(patientUuid) {
|
|
16
|
+
const expectedUrls = [`${fhirBaseUrl}/Patient/${patientUuid}`];
|
|
17
|
+
const absoluteExpectedUrls = expectedUrls.map((url) => window.origin + makeUrl(url));
|
|
18
|
+
const cache = await caches.open('omrs-spa-cache-v1');
|
|
19
|
+
const keys = (await cache.keys()).map((key) => key.url);
|
|
20
|
+
return absoluteExpectedUrls.every((url) => keys.includes(url));
|
|
21
|
+
},
|
|
22
|
+
async sync(patientUuid) {
|
|
23
|
+
await messageOmrsServiceWorker({
|
|
24
|
+
type: 'registerDynamicRoute',
|
|
25
|
+
pattern: `${fhirBaseUrl}/Patient/${patientUuid}`,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
await fetchCurrentPatient(patientUuid, { headers: cacheForOfflineHeaders });
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|