@openmrs/esm-patient-tests-app 11.3.1-patch.9064 → 11.3.1-patch.9508

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 (180) hide show
  1. package/.turbo/turbo-build.log +22 -22
  2. package/dist/1119.js +1 -1
  3. package/dist/1197.js +1 -1
  4. package/dist/{6231.js → 1477.js} +1 -1
  5. package/dist/1477.js.map +1 -0
  6. package/dist/1638.js +1 -1
  7. package/dist/1638.js.map +1 -1
  8. package/dist/1935.js +1 -1
  9. package/dist/1935.js.map +1 -1
  10. package/dist/2146.js +1 -1
  11. package/dist/2690.js +1 -1
  12. package/dist/3099.js +1 -1
  13. package/dist/34.js +1 -1
  14. package/dist/34.js.map +1 -1
  15. package/dist/3509.js +1 -1
  16. package/dist/3509.js.map +1 -1
  17. package/dist/3584.js +1 -1
  18. package/dist/4055.js +1 -1
  19. package/dist/4132.js +1 -1
  20. package/dist/4300.js +1 -1
  21. package/dist/4335.js +1 -1
  22. package/dist/439.js +1 -0
  23. package/dist/4618.js +1 -1
  24. package/dist/4652.js +1 -1
  25. package/dist/4944.js +1 -1
  26. package/dist/5173.js +1 -1
  27. package/dist/5241.js +1 -1
  28. package/dist/5442.js +1 -1
  29. package/dist/5661.js +1 -1
  30. package/dist/5670.js +1 -1
  31. package/dist/5670.js.map +1 -1
  32. package/dist/6022.js +1 -1
  33. package/dist/6113.js +1 -0
  34. package/dist/6113.js.map +1 -0
  35. package/dist/6301.js +1 -1
  36. package/dist/6301.js.map +1 -1
  37. package/dist/6336.js +1 -0
  38. package/dist/6336.js.map +1 -0
  39. package/dist/6468.js +1 -1
  40. package/dist/6589.js +1 -0
  41. package/dist/6679.js +1 -1
  42. package/dist/6840.js +1 -1
  43. package/dist/6859.js +1 -1
  44. package/dist/7097.js +1 -1
  45. package/dist/7159.js +1 -1
  46. package/dist/7202.js +1 -0
  47. package/dist/7202.js.map +1 -0
  48. package/dist/723.js +1 -1
  49. package/dist/7617.js +1 -1
  50. package/dist/790.js +1 -1
  51. package/dist/790.js.map +1 -1
  52. package/dist/795.js +1 -1
  53. package/dist/8163.js +1 -1
  54. package/dist/8307.js +2 -0
  55. package/dist/8307.js.map +1 -0
  56. package/dist/8349.js +1 -1
  57. package/dist/8371.js +1 -0
  58. package/dist/8555.js +2 -0
  59. package/dist/8555.js.map +1 -0
  60. package/dist/8618.js +1 -1
  61. package/dist/890.js +1 -1
  62. package/dist/9214.js +1 -1
  63. package/dist/9538.js +1 -1
  64. package/dist/9569.js +1 -1
  65. package/dist/986.js +1 -1
  66. package/dist/9879.js +1 -1
  67. package/dist/9895.js +1 -1
  68. package/dist/9900.js +1 -1
  69. package/dist/9913.js +1 -1
  70. package/dist/main.js +1 -1
  71. package/dist/main.js.map +1 -1
  72. package/dist/openmrs-esm-patient-tests-app.js +1 -1
  73. package/dist/openmrs-esm-patient-tests-app.js.buildmanifest.json +319 -249
  74. package/dist/openmrs-esm-patient-tests-app.js.map +1 -1
  75. package/dist/routes.json +1 -1
  76. package/package.json +3 -3
  77. package/src/edit-test-results/modal/edit-lab-results.modal.tsx +6 -2
  78. package/src/index.ts +1 -1
  79. package/src/routes.json +2 -2
  80. package/src/test-orders/add-test-order/add-test-order.test.tsx +13 -10
  81. package/src/test-orders/add-test-order/add-test-order.workspace.tsx +43 -7
  82. package/src/test-orders/add-test-order/test-order-form.component.tsx +41 -7
  83. package/src/test-orders/add-test-order/test-type-search.component.tsx +56 -8
  84. package/src/test-orders/lab-order-basket-panel/lab-icon.component.tsx +27 -0
  85. package/src/test-orders/lab-order-basket-panel/lab-order-basket-panel.extension.tsx +62 -15
  86. package/src/test-orders/lab-order-basket-panel/lab-order-basket-panel.scss +26 -11
  87. package/src/test-orders/lab-order-basket-panel/lab-order-basket-panel.test.tsx +18 -5
  88. package/src/test-results/filter/filter-context.test.tsx +556 -0
  89. package/src/test-results/filter/filter-context.tsx +1 -1
  90. package/src/test-results/filter/filter-reducer.test.ts +540 -0
  91. package/src/test-results/filter/filter-reducer.ts +1 -1
  92. package/src/test-results/filter/filter-set.component.tsx +75 -48
  93. package/src/test-results/filter/filter-set.test.tsx +694 -0
  94. package/src/test-results/filter/filter-types.ts +24 -1
  95. package/src/test-results/grouped-timeline/grid.component.tsx +4 -2
  96. package/src/test-results/grouped-timeline/grouped-timeline.component.tsx +20 -22
  97. package/src/test-results/grouped-timeline/grouped-timeline.test.tsx +52 -2
  98. package/src/test-results/grouped-timeline/reference-range-helpers.test.ts +308 -0
  99. package/src/test-results/grouped-timeline/reference-range-helpers.ts +161 -0
  100. package/src/test-results/grouped-timeline/timeline-data-group.component.tsx +13 -6
  101. package/src/test-results/grouped-timeline/useObstreeData.test.ts +471 -0
  102. package/src/test-results/grouped-timeline/useObstreeData.ts +108 -13
  103. package/src/test-results/individual-results-table/individual-results-table.component.tsx +18 -6
  104. package/src/test-results/individual-results-table/individual-results-table.test.tsx +65 -3
  105. package/src/test-results/individual-results-table-tablet/helper.tsx +8 -2
  106. package/src/test-results/individual-results-table-tablet/individual-results-table-tablet.component.tsx +5 -5
  107. package/src/test-results/individual-results-table-tablet/lab-set-panel.component.tsx +2 -1
  108. package/src/test-results/individual-results-table-tablet/usePanelData.tsx +40 -26
  109. package/src/test-results/loadPatientTestData/helpers.test.ts +834 -0
  110. package/src/test-results/loadPatientTestData/helpers.ts +143 -12
  111. package/src/test-results/loadPatientTestData/loadPatientData.ts +66 -11
  112. package/src/test-results/loadPatientTestData/usePatientResultsData.ts +20 -9
  113. package/src/test-results/overview/common-datatable.component.tsx +1 -1
  114. package/src/test-results/overview/external-overview.extension.tsx +1 -2
  115. package/src/test-results/overview/useOverviewData.ts +22 -10
  116. package/src/test-results/print-modal/print-modal.extension.tsx +1 -1
  117. package/src/test-results/results-viewer/results-viewer.extension.tsx +12 -7
  118. package/src/test-results/tree-view/tree-view.component.tsx +31 -8
  119. package/src/test-results/tree-view/tree-view.test.tsx +119 -2
  120. package/src/test-results/trendline/trendline-resource.tsx +48 -5
  121. package/src/test-results/trendline/trendline.component.tsx +88 -52
  122. package/src/test-results/ui-elements/{resetFiltersEmptyState → reset-filters-empty-state}/filter-empty-data-illustration.tsx +2 -2
  123. package/src/test-results/ui-elements/{resetFiltersEmptyState → reset-filters-empty-state}/filter-empty-state.component.tsx +5 -6
  124. package/src/types.ts +20 -1
  125. package/translations/am.json +3 -4
  126. package/translations/ar.json +3 -4
  127. package/translations/ar_SY.json +3 -4
  128. package/translations/bn.json +3 -4
  129. package/translations/cs.json +119 -0
  130. package/translations/de.json +3 -4
  131. package/translations/en.json +3 -2
  132. package/translations/en_US.json +3 -4
  133. package/translations/es.json +3 -4
  134. package/translations/es_MX.json +3 -4
  135. package/translations/fr.json +5 -6
  136. package/translations/he.json +3 -4
  137. package/translations/hi.json +3 -4
  138. package/translations/hi_IN.json +3 -4
  139. package/translations/id.json +3 -4
  140. package/translations/it.json +3 -4
  141. package/translations/ka.json +3 -4
  142. package/translations/km.json +3 -4
  143. package/translations/ku.json +3 -4
  144. package/translations/ky.json +3 -4
  145. package/translations/lg.json +3 -4
  146. package/translations/ne.json +3 -4
  147. package/translations/pl.json +3 -4
  148. package/translations/pt.json +3 -4
  149. package/translations/pt_BR.json +3 -4
  150. package/translations/qu.json +3 -4
  151. package/translations/ro_RO.json +3 -4
  152. package/translations/ru_RU.json +3 -4
  153. package/translations/si.json +3 -4
  154. package/translations/sq.json +119 -0
  155. package/translations/sw.json +3 -4
  156. package/translations/sw_KE.json +3 -4
  157. package/translations/tr.json +3 -4
  158. package/translations/tr_TR.json +3 -4
  159. package/translations/uk.json +3 -4
  160. package/translations/uz.json +3 -4
  161. package/translations/uz@Latn.json +3 -4
  162. package/translations/uz_UZ.json +3 -4
  163. package/translations/vi.json +3 -4
  164. package/translations/zh.json +3 -4
  165. package/translations/zh_CN.json +3 -4
  166. package/translations/zh_TW.json +119 -0
  167. package/dist/1479.js +0 -1
  168. package/dist/1479.js.map +0 -1
  169. package/dist/2537.js +0 -1
  170. package/dist/2537.js.map +0 -1
  171. package/dist/4918.js +0 -1
  172. package/dist/4918.js.map +0 -1
  173. package/dist/5836.js +0 -2
  174. package/dist/5836.js.map +0 -1
  175. package/dist/6231.js.map +0 -1
  176. package/dist/7053.js +0 -2
  177. package/dist/7053.js.map +0 -1
  178. /package/dist/{7053.js.LICENSE.txt → 8307.js.LICENSE.txt} +0 -0
  179. /package/dist/{5836.js.LICENSE.txt → 8555.js.LICENSE.txt} +0 -0
  180. /package/src/test-results/ui-elements/{resetFiltersEmptyState/index.scss → reset-filters-empty-state/filter-empty-state.scss} +0 -0
@@ -9,6 +9,7 @@ import type {
9
9
  NewRowStartCellProps,
10
10
  TimelineDataGroupProps,
11
11
  } from './grouped-timeline-types';
12
+ import { getMostRecentObservationWithRange, formatRangeWithUnits } from './reference-range-helpers';
12
13
  import FilterContext from '../filter/filter-context';
13
14
  import styles from './grouped-timeline.scss';
14
15
 
@@ -130,6 +131,8 @@ const NewRowStartCell: React.FC<NewRowStartCellProps> = ({
130
131
  });
131
132
  }, [patientUuid, conceptUuid, title]);
132
133
 
134
+ const rangeUnitsDisplay = formatRangeWithUnits(range, units);
135
+
133
136
  return (
134
137
  <div
135
138
  className={styles.rowStartCell}
@@ -146,9 +149,7 @@ const NewRowStartCell: React.FC<NewRowStartCellProps> = ({
146
149
  <span className={styles.trendlineLink}>{title}</span>
147
150
  )}
148
151
  </span>
149
- <span className={styles.rangeUnits}>
150
- {range} {units}
151
- </span>
152
+ <span className={styles.rangeUnits}>{rangeUnitsDisplay}</span>
152
153
  </div>
153
154
  );
154
155
  };
@@ -196,14 +197,20 @@ const DataRows: React.FC<DataRowsProps> = ({ patientUuid, timeColumns, rowData,
196
197
  <Grid dataColumns={timeColumns.length} padding style={{ gridColumn: 'span 2' }}>
197
198
  {rowData.map((row, index) => {
198
199
  const obs = row.entries;
199
- const { units = '', range = '', obs: values } = row;
200
+ const { obs: values } = row;
200
201
  const isString = isNaN(parseFloat(values?.[0]?.value));
202
+
203
+ // Note: Units are only at the concept/node level, not observation-level
204
+ const mostRecentObsWithRange = getMostRecentObservationWithRange(row.entries);
205
+ const displayRange = mostRecentObsWithRange?.range ?? row.range ?? '';
206
+ const displayUnits = row.units ?? '';
207
+
201
208
  return (
202
209
  <React.Fragment key={index}>
203
210
  <NewRowStartCell
204
211
  {...{
205
- units,
206
- range,
212
+ units: displayUnits,
213
+ range: displayRange,
207
214
  title: row.display,
208
215
  shadow: showShadow,
209
216
  conceptUuid: row.conceptUuid,
@@ -0,0 +1,471 @@
1
+ import { renderHook, waitFor } from '@testing-library/react';
2
+ import { openmrsFetch } from '@openmrs/esm-framework';
3
+ import { useGetManyObstreeData, useGetObstreeData, type ObsTreeNode } from './useObstreeData';
4
+
5
+ const mockOpenmrsFetch = jest.mocked(openmrsFetch);
6
+
7
+ describe('useObstreeData', () => {
8
+ describe('augmentObstreeData via useGetObstreeData', () => {
9
+ it('should add flatName to nodes', async () => {
10
+ const mockResponse = {
11
+ data: {
12
+ display: 'Hemoglobin',
13
+ conceptUuid: '21AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
14
+ hasData: false,
15
+ subSets: [],
16
+ obs: [
17
+ {
18
+ value: '12.5',
19
+ obsDatetime: '2024-01-01',
20
+ interpretation: 'NORMAL',
21
+ },
22
+ ],
23
+ },
24
+ };
25
+
26
+ mockOpenmrsFetch.mockResolvedValue(mockResponse as any);
27
+
28
+ const { result } = renderHook(() => useGetObstreeData('patient-uuid', 'hemoglobin-uuid'));
29
+
30
+ await waitFor(() => expect(result.current.loading).toBe(false));
31
+
32
+ const data = result.current.data as ObsTreeNode;
33
+ expect(data.flatName).toBe('Hemoglobin');
34
+ expect(data.hasData).toBe(true);
35
+ });
36
+
37
+ it('should build hierarchical flatName for nested nodes', async () => {
38
+ const mockResponse = {
39
+ data: {
40
+ display: 'Complete Blood Count',
41
+ conceptUuid: 'cbc-uuid',
42
+ hasData: false,
43
+ subSets: [
44
+ {
45
+ display: 'Hemoglobin',
46
+ conceptUuid: '21AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
47
+ hasData: false,
48
+ subSets: [],
49
+ obs: [
50
+ {
51
+ value: '12.5',
52
+ obsDatetime: '2024-01-01',
53
+ },
54
+ ],
55
+ },
56
+ ],
57
+ obs: [],
58
+ },
59
+ };
60
+
61
+ mockOpenmrsFetch.mockResolvedValue(mockResponse as any);
62
+
63
+ const { result } = renderHook(() => useGetObstreeData('patient-uuid', 'cbc-uuid'));
64
+
65
+ await waitFor(() => expect(result.current.loading).toBe(false));
66
+
67
+ const data = result.current.data as ObsTreeNode;
68
+ expect(data.flatName).toBe('Complete Blood Count');
69
+ expect(data.subSets[0].flatName).toBe('Complete Blood Count-Hemoglobin');
70
+ });
71
+
72
+ it('should handle Bloodwork prefix specially to avoid long names', async () => {
73
+ const mockResponse = {
74
+ data: {
75
+ display: 'Bloodwork',
76
+ conceptUuid: 'bloodwork-uuid',
77
+ hasData: false,
78
+ subSets: [
79
+ {
80
+ display: 'Hemoglobin',
81
+ conceptUuid: '21AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
82
+ hasData: false,
83
+ subSets: [],
84
+ obs: [{ value: '12.5', obsDatetime: '2024-01-01' }],
85
+ },
86
+ ],
87
+ obs: [],
88
+ },
89
+ };
90
+
91
+ mockOpenmrsFetch.mockResolvedValue(mockResponse as any);
92
+
93
+ const { result } = renderHook(() => useGetObstreeData('patient-uuid', 'bloodwork-uuid'));
94
+
95
+ await waitFor(() => expect(result.current.loading).toBe(false));
96
+
97
+ const data = result.current.data as ObsTreeNode;
98
+ expect(data.flatName).toBe('Bloodwork');
99
+ // Bloodwork children should use simplified names
100
+ expect(data.subSets[0].flatName).toBe('Hemoglobin');
101
+ });
102
+
103
+ it('should set hasData to true when node has observations', async () => {
104
+ const mockResponse = {
105
+ data: {
106
+ display: 'Hemoglobin',
107
+ conceptUuid: '21AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
108
+ hasData: false,
109
+ subSets: [],
110
+ obs: [
111
+ {
112
+ value: '12.5',
113
+ obsDatetime: '2024-01-01',
114
+ },
115
+ ],
116
+ },
117
+ };
118
+
119
+ mockOpenmrsFetch.mockResolvedValue(mockResponse as any);
120
+
121
+ const { result } = renderHook(() => useGetObstreeData('patient-uuid', 'hemoglobin-uuid'));
122
+
123
+ await waitFor(() => expect(result.current.loading).toBe(false));
124
+
125
+ const data = result.current.data as ObsTreeNode;
126
+ expect(data.hasData).toBe(true);
127
+ });
128
+
129
+ it('should propagate hasData to parent when child has data', async () => {
130
+ const mockResponse = {
131
+ data: {
132
+ display: 'Complete Blood Count',
133
+ conceptUuid: 'cbc-uuid',
134
+ hasData: false,
135
+ subSets: [
136
+ {
137
+ display: 'Hemoglobin',
138
+ conceptUuid: '21AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
139
+ hasData: false,
140
+ subSets: [],
141
+ obs: [{ value: '12.5', obsDatetime: '2024-01-01' }],
142
+ },
143
+ ],
144
+ obs: [],
145
+ },
146
+ };
147
+
148
+ mockOpenmrsFetch.mockResolvedValue(mockResponse as any);
149
+
150
+ const { result } = renderHook(() => useGetObstreeData('patient-uuid', 'cbc-uuid'));
151
+
152
+ await waitFor(() => expect(result.current.loading).toBe(false));
153
+
154
+ const data = result.current.data as ObsTreeNode;
155
+ expect(data.hasData).toBe(true);
156
+ expect(data.subSets[0].hasData).toBe(true);
157
+ });
158
+
159
+ it('should add interpretation to observations without one', async () => {
160
+ const mockResponse = {
161
+ data: {
162
+ display: 'Hemoglobin',
163
+ conceptUuid: '21AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
164
+ lowNormal: 10.4,
165
+ hiNormal: 17.8,
166
+ units: 'g/dL',
167
+ hasData: false,
168
+ subSets: [],
169
+ obs: [
170
+ {
171
+ value: '5.0', // Below normal
172
+ obsDatetime: '2024-01-01',
173
+ },
174
+ ],
175
+ },
176
+ };
177
+
178
+ mockOpenmrsFetch.mockResolvedValue(mockResponse as any);
179
+
180
+ const { result } = renderHook(() => useGetObstreeData('patient-uuid', 'hemoglobin-uuid'));
181
+
182
+ await waitFor(() => expect(result.current.loading).toBe(false));
183
+
184
+ // Interpretation is added by assessValue helper
185
+ const data = result.current.data as ObsTreeNode;
186
+ expect(data.obs[0].interpretation).toBeDefined();
187
+ });
188
+
189
+ it('should preserve existing interpretation if present', async () => {
190
+ const mockResponse = {
191
+ data: {
192
+ display: 'Hemoglobin',
193
+ conceptUuid: '21AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
194
+ lowNormal: 10.4,
195
+ hiNormal: 17.8,
196
+ units: 'g/dL',
197
+ hasData: false,
198
+ subSets: [],
199
+ obs: [
200
+ {
201
+ value: '12.5',
202
+ obsDatetime: '2024-01-01',
203
+ interpretation: 'NORMAL',
204
+ },
205
+ ],
206
+ },
207
+ };
208
+
209
+ mockOpenmrsFetch.mockResolvedValue(mockResponse as any);
210
+
211
+ const { result } = renderHook(() => useGetObstreeData('patient-uuid', 'hemoglobin-uuid'));
212
+
213
+ await waitFor(() => expect(result.current.loading).toBe(false));
214
+
215
+ const data = result.current.data as ObsTreeNode;
216
+ expect(data.obs[0].interpretation).toBe('NORMAL');
217
+ });
218
+
219
+ it('should use observation-level reference ranges when available', async () => {
220
+ const mockResponse = {
221
+ data: {
222
+ display: 'Hemoglobin',
223
+ conceptUuid: '21AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
224
+ lowNormal: 10.4,
225
+ hiNormal: 17.8,
226
+ units: 'g/dL',
227
+ hasData: false,
228
+ subSets: [],
229
+ obs: [
230
+ {
231
+ value: '12.5',
232
+ obsDatetime: '2024-01-01',
233
+ // Observation-specific reference ranges (criteria-based)
234
+ lowNormal: 12.0,
235
+ hiNormal: 16.0,
236
+ },
237
+ ],
238
+ },
239
+ };
240
+
241
+ mockOpenmrsFetch.mockResolvedValue(mockResponse as any);
242
+
243
+ const { result } = renderHook(() => useGetObstreeData('patient-uuid', 'hemoglobin-uuid'));
244
+
245
+ await waitFor(() => expect(result.current.loading).toBe(false));
246
+
247
+ // Observation should have a range property (formatted by helper)
248
+ const data = result.current.data as ObsTreeNode;
249
+ expect(data.obs[0]).toHaveProperty('range');
250
+ expect((data.obs[0] as any).range).toBeDefined();
251
+ });
252
+ });
253
+
254
+ describe('filterTreesWithData via useGetManyObstreeData', () => {
255
+ it('should filter out leaf nodes without data', async () => {
256
+ mockOpenmrsFetch.mockResolvedValue({
257
+ data: {
258
+ display: 'Complete Blood Count',
259
+ conceptUuid: 'cbc-uuid',
260
+ hasData: false,
261
+ subSets: [
262
+ {
263
+ display: 'Hemoglobin',
264
+ conceptUuid: '21AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
265
+ hasData: false,
266
+ subSets: [],
267
+ obs: [{ value: '12.5', obsDatetime: '2024-01-01' }],
268
+ },
269
+ {
270
+ display: 'Platelets',
271
+ conceptUuid: 'platelets-uuid',
272
+ hasData: false,
273
+ subSets: [],
274
+ obs: [], // No data
275
+ },
276
+ ],
277
+ obs: [],
278
+ },
279
+ } as any);
280
+
281
+ const { result } = renderHook(() => useGetManyObstreeData('patient-uuid', ['cbc-uuid']));
282
+
283
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
284
+
285
+ expect(result.current.roots).toHaveLength(1);
286
+ expect(result.current.roots[0].subSets).toHaveLength(1);
287
+ expect(result.current.roots[0].subSets[0].display).toBe('Hemoglobin');
288
+ });
289
+
290
+ it('should keep parent nodes even if they have no direct observations', async () => {
291
+ mockOpenmrsFetch.mockResolvedValue({
292
+ data: {
293
+ display: 'Hematology',
294
+ conceptUuid: 'hematology-uuid',
295
+ hasData: false,
296
+ subSets: [
297
+ {
298
+ display: 'Complete Blood Count',
299
+ conceptUuid: 'cbc-uuid',
300
+ hasData: false,
301
+ subSets: [
302
+ {
303
+ display: 'Hemoglobin',
304
+ conceptUuid: '21AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
305
+ hasData: false,
306
+ subSets: [],
307
+ obs: [{ value: '12.5', obsDatetime: '2024-01-01' }],
308
+ },
309
+ ],
310
+ obs: [],
311
+ },
312
+ ],
313
+ obs: [],
314
+ },
315
+ } as any);
316
+
317
+ const { result } = renderHook(() => useGetManyObstreeData('patient-uuid', ['hematology-uuid']));
318
+
319
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
320
+
321
+ expect(result.current.roots).toHaveLength(1);
322
+ expect(result.current.roots[0].display).toBe('Hematology');
323
+ expect(result.current.roots[0].subSets).toHaveLength(1);
324
+ expect(result.current.roots[0].subSets[0].display).toBe('Complete Blood Count');
325
+ });
326
+
327
+ it('should keep parent nodes even when all leaf nodes have no data', async () => {
328
+ mockOpenmrsFetch.mockResolvedValue({
329
+ data: {
330
+ display: 'Complete Blood Count',
331
+ conceptUuid: 'cbc-uuid',
332
+ hasData: false,
333
+ subSets: [
334
+ {
335
+ display: 'Hemoglobin',
336
+ conceptUuid: '21AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
337
+ hasData: false,
338
+ subSets: [],
339
+ obs: [], // No data
340
+ },
341
+ {
342
+ display: 'Hematocrit',
343
+ conceptUuid: 'hematocrit-uuid',
344
+ hasData: false,
345
+ subSets: [],
346
+ obs: [], // No data
347
+ },
348
+ ],
349
+ obs: [],
350
+ },
351
+ } as any);
352
+
353
+ const { result } = renderHook(() => useGetManyObstreeData('patient-uuid', ['cbc-uuid']));
354
+
355
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
356
+
357
+ // Parent node is kept with filtered subSets
358
+ expect(result.current.roots).toHaveLength(1);
359
+ expect(result.current.roots[0].display).toBe('Complete Blood Count');
360
+ });
361
+
362
+ it('should handle nodes that are both parent and have observations', async () => {
363
+ mockOpenmrsFetch.mockResolvedValue({
364
+ data: {
365
+ display: 'Test Node',
366
+ conceptUuid: 'test-uuid',
367
+ hasData: false,
368
+ subSets: [
369
+ {
370
+ display: 'Child Test',
371
+ conceptUuid: 'child-uuid',
372
+ hasData: false,
373
+ subSets: [],
374
+ obs: [{ value: '100', obsDatetime: '2024-01-01' }],
375
+ },
376
+ ],
377
+ obs: [{ value: '50', obsDatetime: '2024-01-01' }], // Parent also has obs
378
+ },
379
+ } as any);
380
+
381
+ const { result } = renderHook(() => useGetManyObstreeData('patient-uuid', ['test-uuid']));
382
+
383
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
384
+
385
+ expect(result.current.roots).toHaveLength(1);
386
+ expect(result.current.roots[0].hasData).toBe(true);
387
+ expect(result.current.roots[0].obs).toHaveLength(1);
388
+ expect(result.current.roots[0].subSets).toHaveLength(1);
389
+ });
390
+ });
391
+
392
+ describe('useGetManyObstreeData', () => {
393
+ it('should tag root nodes with requested conceptUuid', async () => {
394
+ mockOpenmrsFetch.mockResolvedValue({
395
+ data: {
396
+ display: 'Hemoglobin',
397
+ hasData: false,
398
+ subSets: [],
399
+ obs: [{ value: '12.5', obsDatetime: '2024-01-01' }],
400
+ },
401
+ } as any);
402
+
403
+ const { result } = renderHook(() => useGetManyObstreeData('patient-uuid', ['hemoglobin-uuid']));
404
+
405
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
406
+
407
+ expect(result.current.roots[0].conceptUuid).toBe('hemoglobin-uuid');
408
+ });
409
+ });
410
+
411
+ describe('Edge cases', () => {
412
+ it('should handle nodes without subSets or obs', async () => {
413
+ mockOpenmrsFetch.mockResolvedValue({
414
+ data: {
415
+ display: 'Empty Node',
416
+ conceptUuid: 'empty-uuid',
417
+ hasData: false,
418
+ subSets: [],
419
+ obs: [],
420
+ },
421
+ } as any);
422
+
423
+ const { result } = renderHook(() => useGetObstreeData('patient-uuid', 'empty-uuid'));
424
+
425
+ await waitFor(() => expect(result.current.loading).toBe(false));
426
+
427
+ const data = result.current.data as ObsTreeNode;
428
+ expect(data.flatName).toBe('Empty Node');
429
+ // hasData may be set to false or true depending on augmentation logic
430
+ expect(data).toHaveProperty('hasData');
431
+ });
432
+
433
+ it('should handle deeply nested structures', async () => {
434
+ mockOpenmrsFetch.mockResolvedValue({
435
+ data: {
436
+ display: 'Level1',
437
+ conceptUuid: 'level1-uuid',
438
+ hasData: false,
439
+ subSets: [
440
+ {
441
+ display: 'Level2',
442
+ conceptUuid: 'level2-uuid',
443
+ hasData: false,
444
+ subSets: [
445
+ {
446
+ display: 'Level3',
447
+ conceptUuid: 'level3-uuid',
448
+ hasData: false,
449
+ subSets: [],
450
+ obs: [{ value: '100', obsDatetime: '2024-01-01' }],
451
+ },
452
+ ],
453
+ obs: [],
454
+ },
455
+ ],
456
+ obs: [],
457
+ },
458
+ } as any);
459
+
460
+ const { result } = renderHook(() => useGetObstreeData('patient-uuid', 'level1-uuid'));
461
+
462
+ await waitFor(() => expect(result.current.loading).toBe(false));
463
+
464
+ const data = result.current.data as ObsTreeNode;
465
+ expect(data.flatName).toBe('Level1');
466
+ expect(data.subSets[0].flatName).toBe('Level1-Level2');
467
+ expect(data.subSets[0].subSets[0].flatName).toBe('Level1-Level2-Level3');
468
+ expect(data.hasData).toBe(true);
469
+ });
470
+ });
471
+ });
@@ -2,22 +2,41 @@ import { useMemo } from 'react';
2
2
  import useSWR from 'swr';
3
3
  import useSWRInfinite from 'swr/infinite';
4
4
  import { openmrsFetch, restBaseUrl, type FetchResponse } from '@openmrs/esm-framework';
5
- import { usePatientChartStore } from '@openmrs/esm-patient-common-lib';
5
+ import { type OBSERVATION_INTERPRETATION } from '@openmrs/esm-patient-common-lib';
6
6
  import { assessValue, exist } from '../loadPatientTestData/helpers';
7
+ import { selectReferenceRange, formatReferenceRange, type ReferenceRanges } from './reference-range-helpers';
7
8
 
8
9
  export const getName = (prefix: string | undefined, name: string) => {
9
10
  return prefix ? `${prefix}-${name}` : name;
10
11
  };
11
12
 
12
- interface ObsTreeNode {
13
+ export interface ObsTreeNode {
13
14
  flatName?: string;
14
15
  display: string;
16
+ conceptUuid?: string;
15
17
  hasData: boolean;
18
+ hiAbsolute?: number;
19
+ hiCritical?: number;
16
20
  hiNormal?: number;
21
+ lowAbsolute?: number;
22
+ lowCritical?: number;
17
23
  lowNormal?: number;
24
+ units?: string;
18
25
  range?: string;
19
26
  subSets: Array<ObsTreeNode>;
20
- obs: Array<{ value: string }>;
27
+ obs: Array<{
28
+ value: string;
29
+ interpretation?: OBSERVATION_INTERPRETATION;
30
+ obsDatetime?: string;
31
+ // Observation-level reference ranges (criteria-based)
32
+ // Note: Units are only at the concept/node level, not observation-level
33
+ hiAbsolute?: number;
34
+ hiCritical?: number;
35
+ hiNormal?: number;
36
+ lowAbsolute?: number;
37
+ lowCritical?: number;
38
+ lowNormal?: number;
39
+ }>;
21
40
  }
22
41
 
23
42
  const augmentObstreeData = (node: ObsTreeNode, prefix: string | undefined) => {
@@ -40,20 +59,87 @@ const augmentObstreeData = (node: ObsTreeNode, prefix: string | undefined) => {
40
59
  outData.subSets = outData.subSets.map((subNode: ObsTreeNode) => augmentObstreeData(subNode, outData.flatName));
41
60
  outData.hasData = outData.subSets.some((subNode: ObsTreeNode) => subNode.hasData);
42
61
  }
62
+ // Format node-level range for display (using lowNormal/hiNormal)
43
63
  if (exist(outData?.hiNormal, outData?.lowNormal)) {
44
- outData.range = `${outData.lowNormal} – ${outData.hiNormal}`;
64
+ outData.range = formatReferenceRange(
65
+ {
66
+ lowNormal: outData.lowNormal,
67
+ hiNormal: outData.hiNormal,
68
+ units: outData.units,
69
+ },
70
+ outData.units,
71
+ );
45
72
  }
73
+
46
74
  if (outData?.obs?.length) {
47
- const assess = assessValue(outData);
48
- outData.obs = outData.obs.map((ob) => ({ ...ob, interpretation: assess(ob.value) }));
75
+ outData.obs = outData.obs.map((ob) => {
76
+ // Note: Units are only at the concept/node level, not observation-level
77
+ const observationRanges: ReferenceRanges | undefined =
78
+ ob.lowNormal !== undefined || ob.hiNormal !== undefined
79
+ ? {
80
+ hiAbsolute: ob.hiAbsolute,
81
+ hiCritical: ob.hiCritical,
82
+ hiNormal: ob.hiNormal,
83
+ lowAbsolute: ob.lowAbsolute,
84
+ lowCritical: ob.lowCritical,
85
+ lowNormal: ob.lowNormal,
86
+ }
87
+ : undefined;
88
+
89
+ const nodeRanges: ReferenceRanges | undefined = {
90
+ hiAbsolute: outData.hiAbsolute,
91
+ hiCritical: outData.hiCritical,
92
+ hiNormal: outData.hiNormal,
93
+ lowAbsolute: outData.lowAbsolute,
94
+ lowCritical: outData.lowCritical,
95
+ lowNormal: outData.lowNormal,
96
+ units: outData.units,
97
+ };
98
+
99
+ const selectedRanges = selectReferenceRange(observationRanges, nodeRanges);
100
+ const assess = selectedRanges ? assessValue(selectedRanges) : assessValue(nodeRanges);
101
+ const interpretation = ob.interpretation ?? assess(ob.value);
102
+
103
+ // Always use node-level units since observation-level ranges don't have units
104
+ const displayRange = observationRanges
105
+ ? formatReferenceRange(observationRanges, outData.units)
106
+ : outData.range || '--';
107
+
108
+ return {
109
+ ...ob,
110
+ interpretation,
111
+ range: displayRange,
112
+ };
113
+ });
49
114
  outData.hasData = true;
50
115
  }
51
116
 
52
117
  return { ...outData } as ObsTreeNode;
53
118
  };
54
119
 
55
- const useGetObstreeData = (conceptUuid: string) => {
56
- const { patientUuid } = usePatientChartStore();
120
+ const filterTreesWithData = (node: ObsTreeNode): ObsTreeNode | null => {
121
+ // If this is a leaf node (has obs array), only keep it if it has data
122
+ if (node.obs !== undefined) {
123
+ return node.hasData ? node : null;
124
+ }
125
+
126
+ // This is an intermediate/parent node - always keep it to preserve hierarchy
127
+ if (node.subSets && node.subSets.length > 0) {
128
+ // Recursively filter only the leaf children
129
+ const filteredSubSets = node.subSets
130
+ .map((subSet) => filterTreesWithData(subSet))
131
+ .filter((subSet): subSet is ObsTreeNode => subSet !== null);
132
+
133
+ // Always keep parent nodes to maintain test hierarchy structure
134
+ // The UI can choose to grey out parents with no data based on hasData flag
135
+ return { ...node, subSets: filteredSubSets };
136
+ }
137
+
138
+ // Parent node with empty subSets - keep it to preserve hierarchy
139
+ return node;
140
+ };
141
+
142
+ const useGetObstreeData = (patientUuid: string, conceptUuid: string) => {
57
143
  const response = useSWR<FetchResponse<ObsTreeNode>, Error>(
58
144
  `${restBaseUrl}/obstree?patient=${patientUuid}&concept=${conceptUuid}`,
59
145
  openmrsFetch,
@@ -74,8 +160,7 @@ const useGetObstreeData = (conceptUuid: string) => {
74
160
  return result;
75
161
  };
76
162
 
77
- const useGetManyObstreeData = (uuidArray: Array<string>) => {
78
- const { patientUuid } = usePatientChartStore();
163
+ const useGetManyObstreeData = (patientUuid: string, uuidArray: Array<string>) => {
79
164
  const getObstreeUrl = (index: number) => {
80
165
  if (index < uuidArray.length && patientUuid) {
81
166
  return `${restBaseUrl}/obstree?patient=${patientUuid}&concept=${uuidArray[index]}`;
@@ -90,10 +175,14 @@ const useGetManyObstreeData = (uuidArray: Array<string>) => {
90
175
 
91
176
  const result = useMemo(() => {
92
177
  return (
93
- data?.map((resp) => {
178
+ data?.map((resp, index) => {
94
179
  if (resp?.data) {
95
180
  const { data, ...rest } = resp;
96
181
  const newData = augmentObstreeData(data, '');
182
+ // Tag the root node with the conceptUuid we requested
183
+ if (index < uuidArray.length && newData) {
184
+ newData.conceptUuid = uuidArray[index];
185
+ }
97
186
  return { ...rest, loading: false, data: newData };
98
187
  } else {
99
188
  return {
@@ -110,8 +199,14 @@ const useGetManyObstreeData = (uuidArray: Array<string>) => {
110
199
  },
111
200
  ]
112
201
  );
113
- }, [data]);
114
- const roots = result.map((item) => item.data);
202
+ }, [data, uuidArray]);
203
+
204
+ const roots = result
205
+ .map((item) => item.data)
206
+ .filter((node): node is ObsTreeNode => 'display' in node)
207
+ .map((data: ObsTreeNode) => filterTreesWithData(data))
208
+ .filter(Boolean);
209
+
115
210
  const isLoading = result.some((item) => item.loading);
116
211
 
117
212
  return { roots, isLoading, error };