@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.
Files changed (103) hide show
  1. package/.turbo/turbo-build.log +41 -0
  2. package/dist/130.js +2 -0
  3. package/dist/130.js.LICENSE.txt +3 -0
  4. package/dist/130.js.map +1 -0
  5. package/dist/139.js +1 -0
  6. package/dist/139.js.map +1 -0
  7. package/dist/255.js +2 -0
  8. package/dist/255.js.LICENSE.txt +9 -0
  9. package/dist/255.js.map +1 -0
  10. package/dist/271.js +1 -0
  11. package/dist/319.js +1 -0
  12. package/dist/382.js +1 -0
  13. package/dist/382.js.map +1 -0
  14. package/dist/443.js +1 -0
  15. package/dist/443.js.map +1 -0
  16. package/dist/460.js +1 -0
  17. package/dist/548.js +1 -0
  18. package/dist/548.js.map +1 -0
  19. package/dist/574.js +1 -0
  20. package/dist/591.js +2 -0
  21. package/dist/591.js.LICENSE.txt +32 -0
  22. package/dist/591.js.map +1 -0
  23. package/dist/635.js +1 -0
  24. package/dist/635.js.map +1 -0
  25. package/dist/644.js +1 -0
  26. package/dist/729.js +1 -0
  27. package/dist/729.js.map +1 -0
  28. package/dist/757.js +1 -0
  29. package/dist/784.js +2 -0
  30. package/dist/784.js.LICENSE.txt +9 -0
  31. package/dist/784.js.map +1 -0
  32. package/dist/788.js +1 -0
  33. package/dist/807.js +1 -0
  34. package/dist/833.js +1 -0
  35. package/dist/930.js +2 -0
  36. package/dist/930.js.LICENSE.txt +35 -0
  37. package/dist/930.js.map +1 -0
  38. package/dist/kenyaemr-esm-patient-list-management-app.js +1 -0
  39. package/dist/kenyaemr-esm-patient-list-management-app.js.buildmanifest.json +580 -0
  40. package/dist/kenyaemr-esm-patient-list-management-app.js.map +1 -0
  41. package/dist/main.js +2 -0
  42. package/dist/main.js.LICENSE.txt +45 -0
  43. package/dist/main.js.map +1 -0
  44. package/dist/routes.json +1 -0
  45. package/jest.config.js +3 -0
  46. package/package.json +56 -0
  47. package/src/add-patient/add-patient.component.tsx +271 -0
  48. package/src/add-patient/add-patient.scss +51 -0
  49. package/src/add-patient-to-patient-list-menu-item.component.tsx +48 -0
  50. package/src/add-patient-to-patient-list-menu-item.test.tsx +33 -0
  51. package/src/api/api-remote.ts +211 -0
  52. package/src/api/hooks.ts +150 -0
  53. package/src/api/types.ts +102 -0
  54. package/src/config-schema.ts +25 -0
  55. package/src/constants.ts +5 -0
  56. package/src/create-edit-patient-list/create-edit-list.component.tsx +170 -0
  57. package/src/create-edit-patient-list/create-edit-patient-list.scss +31 -0
  58. package/src/createDashboardLink.component.tsx +40 -0
  59. package/src/dashboard.meta.ts +5 -0
  60. package/src/declarations.d.ts +5 -0
  61. package/src/empty-state/empty-data-illustration.component.tsx +42 -0
  62. package/src/empty-state/empty-state.component.tsx +41 -0
  63. package/src/empty-state/empty-state.scss +24 -0
  64. package/src/error-state/error-state.component.tsx +35 -0
  65. package/src/error-state/error-state.scss +50 -0
  66. package/src/header/header.component.tsx +51 -0
  67. package/src/header/header.scss +52 -0
  68. package/src/illo.component.tsx +25 -0
  69. package/src/index.ts +41 -0
  70. package/src/list-details/list-details.component.tsx +201 -0
  71. package/src/list-details/list-details.scss +47 -0
  72. package/src/list-details/list-details.test.tsx +112 -0
  73. package/src/list-details/patient-list-detail.test.tsx +105 -0
  74. package/src/list-details-table/list-details-table.component.tsx +402 -0
  75. package/src/list-details-table/list-details-table.scss +143 -0
  76. package/src/list-details-table/list-details-table.test.tsx +94 -0
  77. package/src/lists-dashboard/lists-dashboard.component.tsx +104 -0
  78. package/src/lists-dashboard/lists-dashboard.scss +110 -0
  79. package/src/lists-dashboard/lists-dashboard.test.tsx +129 -0
  80. package/src/lists-table/custom-pagination.component.tsx +43 -0
  81. package/src/lists-table/custom-pagination.scss +67 -0
  82. package/src/lists-table/lists-table.component.tsx +317 -0
  83. package/src/lists-table/lists-table.scss +100 -0
  84. package/src/lists-table/lists-table.test.tsx +189 -0
  85. package/src/lists-table/use-pagination-info.component.tsx +35 -0
  86. package/src/offline.ts +31 -0
  87. package/src/overlay.component.tsx +49 -0
  88. package/src/overlay.scss +89 -0
  89. package/src/overlay.test.tsx +60 -0
  90. package/src/root.component.tsx +19 -0
  91. package/src/routes.json +36 -0
  92. package/src/style.scss +46 -0
  93. package/translations/am.json +84 -0
  94. package/translations/ar.json +84 -0
  95. package/translations/en.json +84 -0
  96. package/translations/es.json +84 -0
  97. package/translations/fr.json +84 -0
  98. package/translations/he.json +84 -0
  99. package/translations/km.json +84 -0
  100. package/translations/zh.json +84 -0
  101. package/translations/zh_CN.json +84 -0
  102. package/tsconfig.json +5 -0
  103. 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
+ }