@ampath/esm-laboratory-app 1.3.0-next.2

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 (251) hide show
  1. package/.editorconfig +12 -0
  2. package/.eslintignore +2 -0
  3. package/.eslintrc +68 -0
  4. package/.husky/pre-commit +7 -0
  5. package/.husky/pre-push +6 -0
  6. package/.prettierignore +12 -0
  7. package/.prettierrc +8 -0
  8. package/.tx/config +11 -0
  9. package/.yarn/plugins/@yarnpkg/plugin-outdated.cjs +35 -0
  10. package/README.md +54 -0
  11. package/__mocks__/react-i18next.js +50 -0
  12. package/assets/screenshots/labs_enter_results.png +0 -0
  13. package/assets/screenshots/labs_general_dashboard.png +0 -0
  14. package/dist/1119.js +1 -0
  15. package/dist/1197.js +1 -0
  16. package/dist/1222.js +1 -0
  17. package/dist/1222.js.map +1 -0
  18. package/dist/1243.js +1 -0
  19. package/dist/1243.js.map +1 -0
  20. package/dist/2146.js +1 -0
  21. package/dist/2690.js +1 -0
  22. package/dist/3099.js +1 -0
  23. package/dist/3106.js +1 -0
  24. package/dist/3106.js.map +1 -0
  25. package/dist/312.js +1 -0
  26. package/dist/312.js.map +1 -0
  27. package/dist/3352.js +1 -0
  28. package/dist/3352.js.map +1 -0
  29. package/dist/3535.js +1 -0
  30. package/dist/3535.js.map +1 -0
  31. package/dist/3584.js +1 -0
  32. package/dist/4044.js +1 -0
  33. package/dist/4044.js.map +1 -0
  34. package/dist/4055.js +1 -0
  35. package/dist/4132.js +1 -0
  36. package/dist/4300.js +1 -0
  37. package/dist/4335.js +1 -0
  38. package/dist/439.js +1 -0
  39. package/dist/4535.js +1 -0
  40. package/dist/4535.js.map +1 -0
  41. package/dist/4618.js +1 -0
  42. package/dist/4652.js +1 -0
  43. package/dist/4748.js +2 -0
  44. package/dist/4748.js.LICENSE.txt +9 -0
  45. package/dist/4748.js.map +1 -0
  46. package/dist/4920.js +1 -0
  47. package/dist/4920.js.map +1 -0
  48. package/dist/4944.js +1 -0
  49. package/dist/5048.js +2 -0
  50. package/dist/5048.js.LICENSE.txt +29 -0
  51. package/dist/5048.js.map +1 -0
  52. package/dist/5088.js +1 -0
  53. package/dist/5088.js.map +1 -0
  54. package/dist/5173.js +1 -0
  55. package/dist/5241.js +1 -0
  56. package/dist/53.js +1 -0
  57. package/dist/53.js.map +1 -0
  58. package/dist/5339.js +1 -0
  59. package/dist/5339.js.map +1 -0
  60. package/dist/5348.js +1 -0
  61. package/dist/5348.js.map +1 -0
  62. package/dist/5380.js +1 -0
  63. package/dist/5380.js.map +1 -0
  64. package/dist/5442.js +1 -0
  65. package/dist/5661.js +1 -0
  66. package/dist/5780.js +2 -0
  67. package/dist/5780.js.LICENSE.txt +9 -0
  68. package/dist/5780.js.map +1 -0
  69. package/dist/6022.js +1 -0
  70. package/dist/6468.js +1 -0
  71. package/dist/6589.js +1 -0
  72. package/dist/6679.js +1 -0
  73. package/dist/6753.js +1 -0
  74. package/dist/6753.js.map +1 -0
  75. package/dist/6777.js +2 -0
  76. package/dist/6777.js.LICENSE.txt +19 -0
  77. package/dist/6777.js.map +1 -0
  78. package/dist/679.js +2 -0
  79. package/dist/679.js.LICENSE.txt +9 -0
  80. package/dist/679.js.map +1 -0
  81. package/dist/6840.js +1 -0
  82. package/dist/6859.js +1 -0
  83. package/dist/7097.js +1 -0
  84. package/dist/7129.js +1 -0
  85. package/dist/7129.js.map +1 -0
  86. package/dist/7159.js +1 -0
  87. package/dist/723.js +1 -0
  88. package/dist/7617.js +1 -0
  89. package/dist/791.js +1 -0
  90. package/dist/791.js.map +1 -0
  91. package/dist/795.js +1 -0
  92. package/dist/8163.js +1 -0
  93. package/dist/8349.js +1 -0
  94. package/dist/8371.js +1 -0
  95. package/dist/841.js +1 -0
  96. package/dist/841.js.map +1 -0
  97. package/dist/8618.js +1 -0
  98. package/dist/8627.js +2 -0
  99. package/dist/8627.js.LICENSE.txt +25 -0
  100. package/dist/8627.js.map +1 -0
  101. package/dist/8898.js +2 -0
  102. package/dist/8898.js.LICENSE.txt +32 -0
  103. package/dist/8898.js.map +1 -0
  104. package/dist/890.js +1 -0
  105. package/dist/9214.js +1 -0
  106. package/dist/9321.js +1 -0
  107. package/dist/9321.js.map +1 -0
  108. package/dist/9452.js +1 -0
  109. package/dist/9452.js.map +1 -0
  110. package/dist/9538.js +1 -0
  111. package/dist/9569.js +1 -0
  112. package/dist/9695.js +1 -0
  113. package/dist/9695.js.map +1 -0
  114. package/dist/986.js +1 -0
  115. package/dist/9879.js +1 -0
  116. package/dist/9895.js +1 -0
  117. package/dist/9900.js +1 -0
  118. package/dist/9910.js +1 -0
  119. package/dist/9910.js.map +1 -0
  120. package/dist/9913.js +1 -0
  121. package/dist/main.js +2 -0
  122. package/dist/main.js.LICENSE.txt +45 -0
  123. package/dist/main.js.map +1 -0
  124. package/dist/openmrs-esm-laboratory-app.js +1 -0
  125. package/dist/openmrs-esm-laboratory-app.js.buildmanifest.json +1744 -0
  126. package/dist/openmrs-esm-laboratory-app.js.map +1 -0
  127. package/dist/routes.json +1 -0
  128. package/e2e/README.md +117 -0
  129. package/e2e/commands/encounter-operations.ts +63 -0
  130. package/e2e/commands/index.ts +5 -0
  131. package/e2e/commands/patient-operations.ts +109 -0
  132. package/e2e/commands/provider-operations.ts +9 -0
  133. package/e2e/commands/test-order-operations.ts +46 -0
  134. package/e2e/commands/types/index.ts +157 -0
  135. package/e2e/commands/visit-operations.ts +38 -0
  136. package/e2e/core/global-setup.ts +32 -0
  137. package/e2e/core/index.ts +1 -0
  138. package/e2e/core/test.ts +31 -0
  139. package/e2e/fixtures/api.ts +27 -0
  140. package/e2e/fixtures/fhirApi.ts +28 -0
  141. package/e2e/fixtures/index.ts +2 -0
  142. package/e2e/pages/index.ts +1 -0
  143. package/e2e/pages/laboratory-page.ts +28 -0
  144. package/e2e/specs/add-lab-results.spec.ts +111 -0
  145. package/e2e/specs/reject-lab-request.spec.ts +88 -0
  146. package/e2e/specs/test-orders.spec.ts +69 -0
  147. package/e2e/support/github/Dockerfile +34 -0
  148. package/e2e/support/github/docker-compose.yml +24 -0
  149. package/e2e/support/github/run-e2e-docker-env.sh +58 -0
  150. package/example.env +7 -0
  151. package/jest.config.js +35 -0
  152. package/package.json +105 -0
  153. package/playwright.config.ts +42 -0
  154. package/src/components/create-dashboard-link.component.tsx +37 -0
  155. package/src/components/loader/loader.component.tsx +11 -0
  156. package/src/components/loader/loader.scss +9 -0
  157. package/src/components/orders-table/list-order-details.component.tsx +143 -0
  158. package/src/components/orders-table/list-order-details.scss +136 -0
  159. package/src/components/orders-table/order-detail.scss +18 -0
  160. package/src/components/orders-table/orders-data-table.component.tsx +349 -0
  161. package/src/components/orders-table/orders-data-table.scss +129 -0
  162. package/src/components/orders-table/orders-data-table.test.tsx +214 -0
  163. package/src/components/orders-table/orders-date-range-picker.component.tsx +32 -0
  164. package/src/components/orders-table/orders-date-range-picker.scss +7 -0
  165. package/src/components/summary-tile/lab-summary-tile.component.tsx +31 -0
  166. package/src/components/summary-tile/lab-summary-tile.scss +64 -0
  167. package/src/config-schema.ts +39 -0
  168. package/src/constants.ts +2 -0
  169. package/src/declarations.d.ts +2 -0
  170. package/src/index.ts +123 -0
  171. package/src/lab-tabs/actions/actions.scss +26 -0
  172. package/src/lab-tabs/actions/add-lab-request-results-action.component.tsx +46 -0
  173. package/src/lab-tabs/actions/amend-lab-results-action.component.tsx +40 -0
  174. package/src/lab-tabs/actions/approve-lab-results-action.component.tsx +36 -0
  175. package/src/lab-tabs/actions/pickup-lab-request-action.component.tsx +36 -0
  176. package/src/lab-tabs/actions/reject-lab-request-action.component.tsx +36 -0
  177. package/src/lab-tabs/data-table-extensions/completed-lab-requests-table.extension.tsx +8 -0
  178. package/src/lab-tabs/data-table-extensions/declined-lab-requests-table-extension.tsx +8 -0
  179. package/src/lab-tabs/data-table-extensions/in-progress-lab-requests-table.extension.tsx +8 -0
  180. package/src/lab-tabs/data-table-extensions/pending-review-lab-request-table.extension.tsx +8 -0
  181. package/src/lab-tabs/data-table-extensions/tests-ordered-table.extension.tsx +8 -0
  182. package/src/lab-tabs/laboratory-tabs.component.tsx +81 -0
  183. package/src/lab-tabs/laboratory-tabs.scss +38 -0
  184. package/src/lab-tabs/modals/approval-lab-results-modal.component.tsx +70 -0
  185. package/src/lab-tabs/modals/pickup-lab-request-modal.component.tsx +67 -0
  186. package/src/lab-tabs/modals/pickup-lab-request-modal.test.tsx +127 -0
  187. package/src/lab-tabs/modals/reject-lab-request-modal.component.tsx +86 -0
  188. package/src/lab-tabs/modals/reject-lab-request-modal.scss +13 -0
  189. package/src/lab-tabs/modals/reject-lab-request-modal.test.tsx +152 -0
  190. package/src/lab-tiles/all-lab-requests-tile.component.tsx +19 -0
  191. package/src/lab-tiles/completed-lab-requests-tile.component.tsx +19 -0
  192. package/src/lab-tiles/in-progress-lab-requests-tile.component.tsx +19 -0
  193. package/src/lab-tiles/laboratory-summary-tiles.component.tsx +52 -0
  194. package/src/lab-tiles/laboratory-summary-tiles.scss +16 -0
  195. package/src/lab-tiles/pending-review-lab-results-tile.component.tsx +22 -0
  196. package/src/laboratory-dashboard.component.tsx +30 -0
  197. package/src/laboratory-dashboard.scss +5 -0
  198. package/src/laboratory.resource.ts +108 -0
  199. package/src/root.component.tsx +15 -0
  200. package/src/routes.json +204 -0
  201. package/src/types.ts +39 -0
  202. package/tools/i18next-parser.config.js +93 -0
  203. package/tools/index.ts +1 -0
  204. package/tools/setup-tests.ts +8 -0
  205. package/tools/test-utils.ts +44 -0
  206. package/tools/update-openmrs-deps.mjs +42 -0
  207. package/translations/am.json +79 -0
  208. package/translations/ar.json +79 -0
  209. package/translations/ar_SY.json +79 -0
  210. package/translations/bn.json +79 -0
  211. package/translations/cs.json +79 -0
  212. package/translations/de.json +79 -0
  213. package/translations/en.json +79 -0
  214. package/translations/en_US.json +79 -0
  215. package/translations/es.json +79 -0
  216. package/translations/es_MX.json +79 -0
  217. package/translations/fr.json +79 -0
  218. package/translations/he.json +79 -0
  219. package/translations/hi.json +79 -0
  220. package/translations/hi_IN.json +79 -0
  221. package/translations/id.json +79 -0
  222. package/translations/it.json +79 -0
  223. package/translations/ka.json +79 -0
  224. package/translations/km.json +79 -0
  225. package/translations/ku.json +79 -0
  226. package/translations/ky.json +79 -0
  227. package/translations/lg.json +79 -0
  228. package/translations/ne.json +79 -0
  229. package/translations/pl.json +79 -0
  230. package/translations/pt.json +79 -0
  231. package/translations/pt_BR.json +79 -0
  232. package/translations/qu.json +79 -0
  233. package/translations/ro_RO.json +79 -0
  234. package/translations/ru_RU.json +79 -0
  235. package/translations/si.json +79 -0
  236. package/translations/sq.json +79 -0
  237. package/translations/sw.json +79 -0
  238. package/translations/sw_KE.json +79 -0
  239. package/translations/tr.json +79 -0
  240. package/translations/tr_TR.json +79 -0
  241. package/translations/uk.json +79 -0
  242. package/translations/uz.json +79 -0
  243. package/translations/uz@Latn.json +79 -0
  244. package/translations/uz_UZ.json +79 -0
  245. package/translations/vi.json +79 -0
  246. package/translations/zh.json +79 -0
  247. package/translations/zh_CN.json +79 -0
  248. package/translations/zh_TW.json +79 -0
  249. package/tsconfig.json +28 -0
  250. package/turbo.json +15 -0
  251. package/webpack.config.js +25 -0
@@ -0,0 +1,349 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import {
3
+ DataTable,
4
+ DataTableSkeleton,
5
+ Dropdown,
6
+ Layer,
7
+ OverflowMenu,
8
+ OverflowMenuItem,
9
+ Pagination,
10
+ Table,
11
+ TableBody,
12
+ TableCell,
13
+ TableContainer,
14
+ TableExpandedRow,
15
+ TableExpandHeader,
16
+ TableExpandRow,
17
+ TableHead,
18
+ TableHeader,
19
+ TableRow,
20
+ TableToolbar,
21
+ TableToolbarContent,
22
+ TableToolbarSearch,
23
+ Tile,
24
+ } from '@carbon/react';
25
+ import { ExtensionSlot, formatDate, parseDate, showModal, useConfig, usePagination } from '@openmrs/esm-framework';
26
+ import { useTranslation } from 'react-i18next';
27
+ import { type FulfillerStatus, type FlattenedOrder, type Order } from '../../types';
28
+ import { type Config } from '../../config-schema';
29
+ import { useLabOrders } from '../../laboratory.resource';
30
+ import { OrdersDateRangePicker } from './orders-date-range-picker.component';
31
+ import ListOrderDetails from './list-order-details.component';
32
+ import styles from './orders-data-table.scss';
33
+
34
+ const labTableColumnSpec = {
35
+ name: {
36
+ // t('patient', 'Patient')
37
+ headerLabelKey: 'patient',
38
+ headerLabelDefault: 'Patient',
39
+ key: 'patientName',
40
+ },
41
+ age: {
42
+ // t('age', 'Age')
43
+ headerLabelKey: 'age',
44
+ headerLabelDefault: 'Age',
45
+ key: 'patientAge',
46
+ },
47
+ dob: {
48
+ // t('dateOfBirth', 'Date of Birth')
49
+ headerLabelKey: 'dob',
50
+ headerLabelDefault: 'Date of Birth',
51
+ key: 'patientDob',
52
+ },
53
+ sex: {
54
+ // t('sex', 'Sex')
55
+ headerLabelKey: 'sex',
56
+ headerLabelDefault: 'Sex',
57
+ key: 'patientSex',
58
+ },
59
+ totalOrders: {
60
+ // t('totalOrders', 'Total Orders')
61
+ headerLabelKey: 'totalOrders',
62
+ headerLabelDefault: 'Total Orders',
63
+ key: 'totalOrders',
64
+ },
65
+ action: {
66
+ // t('action', 'Action')
67
+ headerLabelKey: 'action',
68
+ headerLabelDefault: 'Action',
69
+ key: 'action',
70
+ },
71
+ patientId: {
72
+ // t('patientId', 'Patient ID')
73
+ headerLabelKey: 'patientId',
74
+ headerLabelDefault: 'Patient ID',
75
+ key: 'patientId',
76
+ },
77
+ };
78
+
79
+ export interface OrdersDataTableProps {
80
+ /* Whether the data table should include a status filter dropdown */
81
+ useFilter?: boolean;
82
+ excludeColumns?: Array<string>;
83
+ fulfillerStatus?: FulfillerStatus;
84
+ newOrdersOnly?: boolean;
85
+ excludeCanceledAndDiscontinuedOrders?: boolean;
86
+ }
87
+
88
+ const OrdersDataTable: React.FC<OrdersDataTableProps> = (props) => {
89
+ const { t } = useTranslation();
90
+ const [filter, setFilter] = useState<FulfillerStatus>(null);
91
+ const [searchString, setSearchString] = useState('');
92
+ const { labTableColumns, patientIdIdentifierTypeUuid } = useConfig<Config>();
93
+
94
+ const { labOrders, isLoading } = useLabOrders({
95
+ status: props.useFilter ? filter : props.fulfillerStatus,
96
+ newOrdersOnly: props.newOrdersOnly,
97
+ excludeCanceled: props.excludeCanceledAndDiscontinuedOrders,
98
+ includePatientId: labTableColumns.includes('patientId'),
99
+ });
100
+
101
+ const flattenedLabOrders: Array<FlattenedOrder> = useMemo(() => {
102
+ return (
103
+ labOrders?.map((order) => {
104
+ return {
105
+ id: order.uuid,
106
+ patientUuid: order.patient.uuid,
107
+ orderNumber: order.orderNumber,
108
+ dateActivated: formatDate(parseDate(order.dateActivated)),
109
+ fulfillerStatus: order.fulfillerStatus,
110
+ urgency: order.urgency,
111
+ orderer: order.orderer?.display,
112
+ instructions: order.instructions,
113
+ fulfillerComment: order.fulfillerComment,
114
+ display: order.display,
115
+ };
116
+ }) ?? []
117
+ );
118
+ }, [labOrders]);
119
+
120
+ const groupedOrdersByPatient = useMemo(() => {
121
+ if (labOrders && labOrders.length > 0) {
122
+ const patientUuids = [...new Set(labOrders.map((order) => order.patient.uuid))];
123
+
124
+ return patientUuids.map((patientUuid) => {
125
+ const labOrdersForPatient = labOrders.filter((order) => order.patient.uuid === patientUuid);
126
+ const patient = labOrdersForPatient[0]?.patient;
127
+ const flattenedLabOrdersForPatient = flattenedLabOrders.filter((order) => order.patientUuid === patientUuid);
128
+ return {
129
+ patientId: patient?.identifiers?.find(
130
+ (identifier) =>
131
+ identifier.preferred &&
132
+ !identifier.voided &&
133
+ identifier.identifierType.uuid === patientIdIdentifierTypeUuid,
134
+ )?.identifier,
135
+ patientUuid: patientUuid,
136
+ patientName: patient?.person?.display,
137
+ patientAge: patient?.person?.age,
138
+ patientDob: patient?.person?.birthdate ? formatDate(parseDate(patient.person.birthdate)) : undefined,
139
+ patientSex: patient?.person?.gender,
140
+ totalOrders: flattenedLabOrdersForPatient.length,
141
+ orders: flattenedLabOrdersForPatient,
142
+ originalOrders: labOrdersForPatient,
143
+ };
144
+ });
145
+ } else {
146
+ return [];
147
+ }
148
+ }, [flattenedLabOrders, labOrders, patientIdIdentifierTypeUuid]);
149
+
150
+ const searchResults = useMemo(() => {
151
+ if (searchString && searchString.trim() !== '') {
152
+ // Normalize the search string to lowercase
153
+ const lowerSearchString = searchString.toLowerCase();
154
+ return groupedOrdersByPatient.filter(
155
+ (orderGroup) =>
156
+ (labTableColumns.includes('name') && orderGroup.patientName?.toLowerCase().includes(lowerSearchString)) ||
157
+ (labTableColumns.includes('patientId') && orderGroup.patientId?.toLowerCase().includes(lowerSearchString)) ||
158
+ orderGroup.orders.some((order) => order.orderNumber?.toLowerCase().includes(lowerSearchString)),
159
+ );
160
+ }
161
+
162
+ return groupedOrdersByPatient;
163
+ }, [searchString, groupedOrdersByPatient, labTableColumns]);
164
+
165
+ const orderStatuses = [
166
+ { value: null, display: t('all', 'All') },
167
+ { value: 'RECEIVED', display: t('receivedStatus', 'RECEIVED') },
168
+ { value: 'IN_PROGRESS', display: t('inProgressStatus', 'IN_PROGRESS') },
169
+ { value: 'COMPLETED', display: t('completedStatus', 'COMPLETED') },
170
+ { value: 'EXCEPTION', display: t('exceptionStatus', 'EXCEPTION') },
171
+ { value: 'ON_HOLD', display: t('onHoldStatus', 'ON_HOLD') },
172
+ { value: 'DECLINED', display: t('declinedStatus', 'DECLINED') },
173
+ ];
174
+
175
+ const columns = useMemo(() => {
176
+ return labTableColumns
177
+ .map((column) => {
178
+ const spec = labTableColumnSpec[column];
179
+ if (!spec) {
180
+ throw new Error(`Lab table has been configured with an invalid column: ${column}`);
181
+ }
182
+ if (spec.key === 'action') {
183
+ const showActionColumn = flattenedLabOrders.some((order) => order.fulfillerStatus === 'COMPLETED');
184
+ if (!showActionColumn) {
185
+ return null;
186
+ }
187
+ }
188
+ return { header: t(spec.headerLabelKey, spec.headerLabelDefault), key: spec.key };
189
+ })
190
+ .filter(Boolean)
191
+ .map((column) => ({ ...column, id: column.key }));
192
+ }, [t, flattenedLabOrders, labTableColumns]);
193
+
194
+ const pageSizes = [10, 20, 30, 40, 50];
195
+ const [currentPageSize, setPageSize] = useState(10);
196
+ const { goTo, results: paginatedLabOrders, currentPage } = usePagination(searchResults, currentPageSize);
197
+
198
+ const handleOrderStatusChange = ({ selectedItem }: { selectedItem: { value: FulfillerStatus; display: string } }) =>
199
+ setFilter(selectedItem.value);
200
+
201
+ const handlePrintModal = (orders: Array<Order>) => {
202
+ const completedOrders = orders.filter((order) => order.fulfillerStatus === 'COMPLETED');
203
+ const dispose = showModal('print-lab-results-modal', {
204
+ closeModal: () => dispose(),
205
+ orders: completedOrders,
206
+ });
207
+ };
208
+
209
+ const handleLaunchModal = (orders: Array<Order>) => {
210
+ const completedOrders = orders.filter((order) => order.fulfillerStatus === 'COMPLETED');
211
+ const dispose = showModal('edit-lab-results-modal', {
212
+ orders: completedOrders,
213
+ closeModal: () => dispose(),
214
+ patient: completedOrders[0]?.patient,
215
+ workspaceName: 'lab-app-test-results-form-workspace',
216
+ });
217
+ };
218
+
219
+ const tableRows = useMemo(() => {
220
+ return paginatedLabOrders.map((groupedOrder) => ({
221
+ ...groupedOrder,
222
+ id: groupedOrder.patientUuid,
223
+ action: groupedOrder.orders.some((o) => o.fulfillerStatus === 'COMPLETED') ? (
224
+ <div className={styles.actionCell}>
225
+ <OverflowMenu aria-label="Actions" flipped iconDescription="Actions">
226
+ <ExtensionSlot
227
+ className={styles.transitionOverflowMenuItemSlot}
228
+ name="transition-overflow-menu-item-slot"
229
+ state={{ patientUuid: groupedOrder.patientUuid }}
230
+ // Without tabIndex={0} here, the overflow menu incorrectly sets initial focus to the second item instead of the first.
231
+ tabIndex={0}
232
+ />
233
+ <OverflowMenuItem
234
+ className={styles.menuitem}
235
+ itemText={t('editResults', 'Edit results')}
236
+ onClick={() => handleLaunchModal(groupedOrder.originalOrders)}
237
+ />
238
+ <OverflowMenuItem
239
+ className={styles.menuitem}
240
+ itemText={t('printTestResults', 'Print test results')}
241
+ onClick={() => handlePrintModal(groupedOrder.originalOrders)}
242
+ />
243
+ </OverflowMenu>
244
+ </div>
245
+ ) : null,
246
+ }));
247
+ }, [paginatedLabOrders, t]);
248
+
249
+ if (isLoading) {
250
+ return <DataTableSkeleton role="progressbar" showHeader={false} showToolbar={false} />;
251
+ }
252
+
253
+ return (
254
+ <DataTable rows={tableRows} headers={columns} useZebraStyles>
255
+ {({ getExpandHeaderProps, getHeaderProps, getRowProps, getTableProps, headers, rows }) => (
256
+ <TableContainer className={styles.tableContainer}>
257
+ <TableToolbar>
258
+ <TableToolbarContent className={styles.tableToolBar}>
259
+ <Layer className={styles.toolbarItem}>
260
+ {props.useFilter && (
261
+ <Dropdown
262
+ id="orderStatusFilter"
263
+ initialSelectedItem={
264
+ filter ? orderStatuses.find((status) => status.value === filter) : orderStatuses[0]
265
+ }
266
+ items={orderStatuses}
267
+ itemToString={(item) => item?.display}
268
+ label=""
269
+ onChange={handleOrderStatusChange}
270
+ titleText={t('filterOrdersByStatus', 'Filter orders by status') + ':'}
271
+ type="inline"
272
+ />
273
+ )}
274
+ <OrdersDateRangePicker />
275
+ </Layer>
276
+ <Layer className={styles.toolbarItem}>
277
+ <TableToolbarSearch
278
+ expanded
279
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchString(e.target.value)}
280
+ placeholder={t('searchThisList', 'Search this list')}
281
+ size="sm"
282
+ />
283
+ </Layer>
284
+ </TableToolbarContent>
285
+ </TableToolbar>
286
+ <Table className={styles.tableWrapper} {...getTableProps()}>
287
+ <TableHead>
288
+ <TableRow>
289
+ <TableExpandHeader enableToggle {...getExpandHeaderProps()} />
290
+ {headers.map((header) => (
291
+ <TableHeader {...getHeaderProps({ header })}>{header.header}</TableHeader>
292
+ ))}
293
+ </TableRow>
294
+ </TableHead>
295
+ <TableBody>
296
+ {rows.map((row) => (
297
+ <React.Fragment key={row.id}>
298
+ <TableExpandRow {...getRowProps({ row })} key={row.id}>
299
+ {row.cells.map((cell) => (
300
+ <TableCell key={cell.id}>{cell.value?.content ?? cell.value}</TableCell>
301
+ ))}
302
+ </TableExpandRow>
303
+ {row.isExpanded ? (
304
+ <TableExpandedRow colSpan={headers.length + 2}>
305
+ <ListOrderDetails
306
+ groupedOrders={groupedOrdersByPatient.find((item) => item.patientUuid === row.id)}
307
+ />
308
+ </TableExpandedRow>
309
+ ) : (
310
+ <TableExpandedRow className={styles.hiddenRow} colSpan={headers.length + 2} />
311
+ )}
312
+ </React.Fragment>
313
+ ))}
314
+ </TableBody>
315
+ </Table>
316
+ {rows.length === 0 ? (
317
+ <div className={styles.tileContainer}>
318
+ <Tile className={styles.tile}>
319
+ <div className={styles.tileContent}>
320
+ <p className={styles.content}>{t('noLabRequestsFound', 'No lab requests found')}</p>
321
+ <p className={styles.emptyStateHelperText}>
322
+ {t('checkFilters', 'Please check the filters above and try again')}
323
+ </p>
324
+ </div>
325
+ </Tile>
326
+ </div>
327
+ ) : null}
328
+ {rows.length > 0 && (
329
+ <Pagination
330
+ forwardText={t('nextPage', 'Next page')}
331
+ backwardText={t('previousPage', 'Previous page')}
332
+ page={currentPage}
333
+ pageSize={currentPageSize}
334
+ pageSizes={pageSizes}
335
+ totalItems={searchResults?.length}
336
+ className={styles.pagination}
337
+ onChange={({ pageSize, page }) => {
338
+ if (pageSize !== currentPageSize) setPageSize(pageSize);
339
+ if (page !== currentPage) goTo(page);
340
+ }}
341
+ />
342
+ )}
343
+ </TableContainer>
344
+ )}
345
+ </DataTable>
346
+ );
347
+ };
348
+
349
+ export default OrdersDataTable;
@@ -0,0 +1,129 @@
1
+ @use '@carbon/colors';
2
+ @use '@carbon/layout';
3
+ @use '@carbon/type';
4
+
5
+ .tableContainer {
6
+ border: 1px solid colors.$gray-20;
7
+ background-color: colors.$gray-10;
8
+ padding: 0;
9
+
10
+ :global(.cds--data-table-content) {
11
+ overflow-x: unset;
12
+ }
13
+
14
+ a {
15
+ text-decoration: none;
16
+ }
17
+
18
+ th {
19
+ color: colors.$gray-70;
20
+ }
21
+
22
+ :global(.cds--data-table) {
23
+ background-color: colors.$gray-20;
24
+ }
25
+
26
+ :global(.cds--table-toolbar) {
27
+ position: static;
28
+ }
29
+
30
+ :global(.cds--overflow-menu) {
31
+ background-color: transparent;
32
+ border: none;
33
+ }
34
+
35
+ :global(.cds--pagination) {
36
+ border-top: none;
37
+ }
38
+
39
+ .toolbarContent {
40
+ height: layout.$spacing-07;
41
+ margin-bottom: layout.$spacing-02;
42
+ }
43
+ }
44
+
45
+ .toolbarItem {
46
+ margin-left: layout.$spacing-05;
47
+ display: flex;
48
+ align-items: center;
49
+
50
+ & p {
51
+ padding-right: layout.$spacing-03;
52
+ @include type.type-style('body-01');
53
+ color: colors.$gray-70;
54
+ }
55
+
56
+ :global(.cds--date-picker__input) {
57
+ height: layout.$spacing-09;
58
+ }
59
+
60
+ :global(.cds--dropdown) {
61
+ height: layout.$spacing-09;
62
+ }
63
+
64
+ :global(.cds--list-box__menu:focus) {
65
+ outline: none;
66
+ }
67
+ }
68
+
69
+ .tableWrapper tr:last-of-type {
70
+ td {
71
+ border-bottom: none;
72
+ }
73
+ }
74
+
75
+ .tileContainer {
76
+ background-color: colors.$white;
77
+ border-top: 1px solid colors.$gray-20;
78
+ padding: 3rem 0;
79
+ }
80
+
81
+ .tile {
82
+ margin: auto;
83
+ width: fit-content;
84
+ }
85
+
86
+ .tileContent {
87
+ display: flex;
88
+ flex-direction: column;
89
+ align-items: center;
90
+ }
91
+
92
+ .content {
93
+ @include type.type-style('heading-compact-02');
94
+ color: colors.$gray-70;
95
+ margin-bottom: layout.$spacing-03;
96
+ }
97
+
98
+ .singleLineDisplay {
99
+ white-space: nowrap;
100
+ }
101
+
102
+ .emptyStateHelperText {
103
+ @include type.type-style('helper-text-02');
104
+ }
105
+
106
+ .hiddenRow {
107
+ display: none;
108
+ }
109
+
110
+ .tableToolBar {
111
+ height: auto;
112
+ align-items: flex-end;
113
+
114
+ :global(.cds--toolbar-search-container-expandable) {
115
+ align-self: flex-end;
116
+ }
117
+ }
118
+
119
+ .menuitem {
120
+ max-width: none;
121
+ }
122
+
123
+ .transitionOverflowMenuItemSlot {
124
+ min-width: 100%;
125
+
126
+ :global(.cds--overflow-menu-options__btn) {
127
+ min-width: 100%;
128
+ }
129
+ }
@@ -0,0 +1,214 @@
1
+ import React from 'react';
2
+ import { render, screen, within } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { useConfig, getDefaultsFromConfigSchema, type Order, type Patient } from '@openmrs/esm-framework';
5
+ import { configSchema, type Config } from '../../config-schema';
6
+ import { useLabOrders } from '../../laboratory.resource';
7
+ import OrdersDataTable from './orders-data-table.component';
8
+
9
+ jest.mock('../../laboratory.resource', () => ({
10
+ useLabOrders: jest.fn(),
11
+ }));
12
+
13
+ const mockUseConfig = jest.mocked(useConfig<Config>);
14
+ const mockUseLabOrders = jest.mocked(useLabOrders);
15
+
16
+ function mockUseLabOrdersImplementation(props: Parameters<typeof useLabOrders>[0]) {
17
+ const mockPatient1: Partial<Patient> = {
18
+ uuid: 'patient-uuid-1',
19
+ display: 'PAT-001 - Pete Seeger',
20
+ identifiers: props.includePatientId
21
+ ? [
22
+ {
23
+ uuid: 'identifier-uuid-1',
24
+ identifier: 'PAT-001',
25
+ preferred: true,
26
+ voided: false,
27
+ identifierType: {
28
+ uuid: 'identifier-type-uuid-1',
29
+ },
30
+ },
31
+ {
32
+ uuid: 'identifier-uuid-2',
33
+ identifier: 'BAD-ID-NOT-PREFERRED',
34
+ preferred: false,
35
+ voided: false,
36
+ identifierType: {
37
+ uuid: 'identifier-type-uuid-1',
38
+ },
39
+ },
40
+ ]
41
+ : undefined,
42
+ person: {
43
+ uuid: 'person-uuid-1',
44
+ display: 'Pete Seeger',
45
+ age: 70,
46
+ gender: 'M',
47
+ },
48
+ };
49
+ const mockPatient2: Partial<Patient> = {
50
+ uuid: 'patient-uuid-2',
51
+ display: 'PAT-002 - Bob Dylan',
52
+ identifiers: props.includePatientId
53
+ ? [
54
+ {
55
+ uuid: 'identifier-uuid-3',
56
+ identifier: 'BAD-ID-WRONG-TYPE',
57
+ preferred: true,
58
+ voided: false,
59
+ identifierType: {
60
+ uuid: '05a29f94-c0ed-11e2-94be-8c13b969e334',
61
+ },
62
+ },
63
+ {
64
+ uuid: 'identifier-uuid-4',
65
+ identifier: 'PAT-002',
66
+ preferred: true,
67
+ voided: false,
68
+ identifierType: {
69
+ uuid: 'identifier-type-uuid-1',
70
+ },
71
+ },
72
+ ]
73
+ : undefined,
74
+ person: {
75
+ uuid: 'person-uuid-2',
76
+ display: 'Bob Dylan',
77
+ age: 60,
78
+ gender: 'M',
79
+ },
80
+ };
81
+
82
+ const mockOrderer = {
83
+ uuid: 'orderer-uuid-1',
84
+ display: 'Dr. John Doe',
85
+ person: {
86
+ display: 'Dr. John Doe',
87
+ },
88
+ };
89
+
90
+ const labOrders = [
91
+ {
92
+ uuid: 'order-uuid-1',
93
+ orderNumber: 'ORD-001',
94
+ patient: mockPatient1 as Patient,
95
+ dateActivated: '2021-01-01',
96
+ fulfillerStatus: 'RECEIVED',
97
+ urgency: 'ROUTINE',
98
+ orderer: mockOrderer,
99
+ instructions: 'Inspect banjo & check tuning',
100
+ fulfillerComment: null,
101
+ display: 'Banjo Inspection',
102
+ },
103
+ {
104
+ uuid: 'order-uuid-2',
105
+ orderNumber: 'ORD-002',
106
+ patient: mockPatient1 as Patient,
107
+ dateActivated: '2021-01-01',
108
+ fulfillerStatus: 'RECEIVED',
109
+ urgency: 'ROUTINE',
110
+ orderer: mockOrderer,
111
+ instructions: 'Give it a strum',
112
+ fulfillerComment: null,
113
+ display: 'Guitar Inspection',
114
+ },
115
+ {
116
+ uuid: 'order-uuid-3',
117
+ orderNumber: 'ORD-003',
118
+ patient: mockPatient2 as Patient,
119
+ dateActivated: '2021-01-01',
120
+ fulfillerStatus: 'RECEIVED',
121
+ urgency: 'EMERGENCY',
122
+ orderer: mockOrderer,
123
+ instructions: 'Make some noise',
124
+ fulfillerComment: null,
125
+ display: 'Sound Check',
126
+ },
127
+ ]
128
+ .filter((order) => !props.status || order.fulfillerStatus === props.status)
129
+ .filter((order) => !props.excludeCanceled || order.fulfillerStatus !== 'CANCELLED') as Array<Order>;
130
+ return {
131
+ labOrders,
132
+ isLoading: false,
133
+ isError: false,
134
+ mutate: jest.fn(),
135
+ isValidating: false,
136
+ };
137
+ }
138
+
139
+ describe('OrdersDataTable', () => {
140
+ beforeEach(() => {
141
+ mockUseLabOrders.mockImplementation(mockUseLabOrdersImplementation);
142
+ });
143
+
144
+ it('should render one row per patient and show lab details', async () => {
145
+ mockUseConfig.mockReturnValue({
146
+ ...getDefaultsFromConfigSchema(configSchema),
147
+ });
148
+
149
+ render(<OrdersDataTable />);
150
+ const table = screen.getByRole('table');
151
+ expect(table).toBeInTheDocument();
152
+ const rows = screen.getAllByRole('row');
153
+ expect(rows).toHaveLength(5);
154
+ const dataRows = rows.slice(1).filter((row) => !row.classList.contains('hiddenRow'));
155
+ expect(dataRows).toHaveLength(2);
156
+ const headerRow = rows[0];
157
+ expect(headerRow).toHaveTextContent('Patient');
158
+ expect(headerRow).toHaveTextContent('Age');
159
+ expect(headerRow).toHaveTextContent('Sex');
160
+ expect(headerRow).toHaveTextContent('Total Orders');
161
+ const row1 = dataRows[0];
162
+ expect(row1).toHaveTextContent('Pete Seeger');
163
+ expect(row1).toHaveTextContent('70');
164
+ expect(row1).toHaveTextContent('M');
165
+ expect(row1).toHaveTextContent('2');
166
+ const row2 = dataRows[1];
167
+ expect(row2).toHaveTextContent('Bob Dylan');
168
+ expect(row2).toHaveTextContent('60');
169
+ expect(row2).toHaveTextContent('M');
170
+ expect(row2).toHaveTextContent('1');
171
+
172
+ const user = userEvent.setup();
173
+ await user.click(within(row1).getByLabelText('Expand current row'));
174
+
175
+ const orderDetailsTables = within(table).getAllByRole('table');
176
+ expect(orderDetailsTables).toHaveLength(2);
177
+ const orderDetailsTable1 = orderDetailsTables[0];
178
+ const orderDetailsTable2 = orderDetailsTables[1];
179
+ expect(orderDetailsTable1).toHaveTextContent('Banjo Inspection');
180
+ expect(orderDetailsTable1).toHaveTextContent('Inspect banjo & check tuning');
181
+ expect(orderDetailsTable1).toHaveTextContent('Dr. John Doe');
182
+ expect(orderDetailsTable1).toHaveTextContent('01-Jan-2021');
183
+ expect(orderDetailsTable1).toHaveTextContent('Received');
184
+ expect(orderDetailsTable1).toHaveTextContent('Routine');
185
+ expect(orderDetailsTable2).toHaveTextContent('Guitar Inspection');
186
+ expect(orderDetailsTable2).toHaveTextContent('Give it a strum');
187
+ expect(orderDetailsTable2).toHaveTextContent('Dr. John Doe');
188
+ expect(orderDetailsTable2).toHaveTextContent('01-Jan-2021');
189
+ expect(orderDetailsTable2).toHaveTextContent('Received');
190
+ expect(orderDetailsTable2).toHaveTextContent('Routine');
191
+ });
192
+
193
+ it('should show patient identifier if it is configured', () => {
194
+ mockUseConfig.mockReturnValue({
195
+ ...getDefaultsFromConfigSchema(configSchema),
196
+ labTableColumns: ['patientId', 'age', 'totalOrders'],
197
+ patientIdIdentifierTypeUuid: 'identifier-type-uuid-1',
198
+ });
199
+ render(<OrdersDataTable />);
200
+ const rows = screen.getAllByRole('row');
201
+ expect(rows).toHaveLength(5);
202
+ const dataRows = rows.slice(1).filter((row) => !row.classList.contains('hiddenRow'));
203
+ expect(dataRows).toHaveLength(2);
204
+ const row1 = dataRows[0];
205
+ expect(row1).toHaveTextContent('PAT-001');
206
+ expect(row1).toHaveTextContent('70');
207
+ expect(row1).toHaveTextContent('2');
208
+ const row2 = dataRows[1];
209
+ expect(row2).toHaveTextContent('PAT-002');
210
+ expect(row2).toHaveTextContent('60');
211
+ expect(row2).toHaveTextContent('1');
212
+ expect(screen.queryByText(/BAD-ID/)).not.toBeInTheDocument();
213
+ });
214
+ });