@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.
- package/.turbo/turbo-build.log +22 -22
- package/dist/1119.js +1 -1
- package/dist/1197.js +1 -1
- package/dist/{6231.js → 1477.js} +1 -1
- package/dist/1477.js.map +1 -0
- package/dist/1638.js +1 -1
- package/dist/1638.js.map +1 -1
- package/dist/1935.js +1 -1
- package/dist/1935.js.map +1 -1
- package/dist/2146.js +1 -1
- package/dist/2690.js +1 -1
- package/dist/3099.js +1 -1
- package/dist/34.js +1 -1
- package/dist/34.js.map +1 -1
- package/dist/3509.js +1 -1
- package/dist/3509.js.map +1 -1
- package/dist/3584.js +1 -1
- package/dist/4055.js +1 -1
- package/dist/4132.js +1 -1
- package/dist/4300.js +1 -1
- package/dist/4335.js +1 -1
- package/dist/439.js +1 -0
- package/dist/4618.js +1 -1
- package/dist/4652.js +1 -1
- package/dist/4944.js +1 -1
- package/dist/5173.js +1 -1
- package/dist/5241.js +1 -1
- package/dist/5442.js +1 -1
- package/dist/5661.js +1 -1
- package/dist/5670.js +1 -1
- package/dist/5670.js.map +1 -1
- package/dist/6022.js +1 -1
- package/dist/6113.js +1 -0
- package/dist/6113.js.map +1 -0
- package/dist/6301.js +1 -1
- package/dist/6301.js.map +1 -1
- package/dist/6336.js +1 -0
- package/dist/6336.js.map +1 -0
- package/dist/6468.js +1 -1
- package/dist/6589.js +1 -0
- package/dist/6679.js +1 -1
- package/dist/6840.js +1 -1
- package/dist/6859.js +1 -1
- package/dist/7097.js +1 -1
- package/dist/7159.js +1 -1
- package/dist/7202.js +1 -0
- package/dist/7202.js.map +1 -0
- package/dist/723.js +1 -1
- package/dist/7617.js +1 -1
- package/dist/790.js +1 -1
- package/dist/790.js.map +1 -1
- package/dist/795.js +1 -1
- package/dist/8163.js +1 -1
- package/dist/8307.js +2 -0
- package/dist/8307.js.map +1 -0
- package/dist/8349.js +1 -1
- package/dist/8371.js +1 -0
- package/dist/8555.js +2 -0
- package/dist/8555.js.map +1 -0
- package/dist/8618.js +1 -1
- package/dist/890.js +1 -1
- package/dist/9214.js +1 -1
- package/dist/9538.js +1 -1
- package/dist/9569.js +1 -1
- package/dist/986.js +1 -1
- package/dist/9879.js +1 -1
- package/dist/9895.js +1 -1
- package/dist/9900.js +1 -1
- package/dist/9913.js +1 -1
- package/dist/main.js +1 -1
- package/dist/main.js.map +1 -1
- package/dist/openmrs-esm-patient-tests-app.js +1 -1
- package/dist/openmrs-esm-patient-tests-app.js.buildmanifest.json +319 -249
- package/dist/openmrs-esm-patient-tests-app.js.map +1 -1
- package/dist/routes.json +1 -1
- package/package.json +3 -3
- package/src/edit-test-results/modal/edit-lab-results.modal.tsx +6 -2
- package/src/index.ts +1 -1
- package/src/routes.json +2 -2
- package/src/test-orders/add-test-order/add-test-order.test.tsx +13 -10
- package/src/test-orders/add-test-order/add-test-order.workspace.tsx +43 -7
- package/src/test-orders/add-test-order/test-order-form.component.tsx +41 -7
- package/src/test-orders/add-test-order/test-type-search.component.tsx +56 -8
- package/src/test-orders/lab-order-basket-panel/lab-icon.component.tsx +27 -0
- package/src/test-orders/lab-order-basket-panel/lab-order-basket-panel.extension.tsx +62 -15
- package/src/test-orders/lab-order-basket-panel/lab-order-basket-panel.scss +26 -11
- package/src/test-orders/lab-order-basket-panel/lab-order-basket-panel.test.tsx +18 -5
- package/src/test-results/filter/filter-context.test.tsx +556 -0
- package/src/test-results/filter/filter-context.tsx +1 -1
- package/src/test-results/filter/filter-reducer.test.ts +540 -0
- package/src/test-results/filter/filter-reducer.ts +1 -1
- package/src/test-results/filter/filter-set.component.tsx +75 -48
- package/src/test-results/filter/filter-set.test.tsx +694 -0
- package/src/test-results/filter/filter-types.ts +24 -1
- package/src/test-results/grouped-timeline/grid.component.tsx +4 -2
- package/src/test-results/grouped-timeline/grouped-timeline.component.tsx +20 -22
- package/src/test-results/grouped-timeline/grouped-timeline.test.tsx +52 -2
- package/src/test-results/grouped-timeline/reference-range-helpers.test.ts +308 -0
- package/src/test-results/grouped-timeline/reference-range-helpers.ts +161 -0
- package/src/test-results/grouped-timeline/timeline-data-group.component.tsx +13 -6
- package/src/test-results/grouped-timeline/useObstreeData.test.ts +471 -0
- package/src/test-results/grouped-timeline/useObstreeData.ts +108 -13
- package/src/test-results/individual-results-table/individual-results-table.component.tsx +18 -6
- package/src/test-results/individual-results-table/individual-results-table.test.tsx +65 -3
- package/src/test-results/individual-results-table-tablet/helper.tsx +8 -2
- package/src/test-results/individual-results-table-tablet/individual-results-table-tablet.component.tsx +5 -5
- package/src/test-results/individual-results-table-tablet/lab-set-panel.component.tsx +2 -1
- package/src/test-results/individual-results-table-tablet/usePanelData.tsx +40 -26
- package/src/test-results/loadPatientTestData/helpers.test.ts +834 -0
- package/src/test-results/loadPatientTestData/helpers.ts +143 -12
- package/src/test-results/loadPatientTestData/loadPatientData.ts +66 -11
- package/src/test-results/loadPatientTestData/usePatientResultsData.ts +20 -9
- package/src/test-results/overview/common-datatable.component.tsx +1 -1
- package/src/test-results/overview/external-overview.extension.tsx +1 -2
- package/src/test-results/overview/useOverviewData.ts +22 -10
- package/src/test-results/print-modal/print-modal.extension.tsx +1 -1
- package/src/test-results/results-viewer/results-viewer.extension.tsx +12 -7
- package/src/test-results/tree-view/tree-view.component.tsx +31 -8
- package/src/test-results/tree-view/tree-view.test.tsx +119 -2
- package/src/test-results/trendline/trendline-resource.tsx +48 -5
- package/src/test-results/trendline/trendline.component.tsx +88 -52
- package/src/test-results/ui-elements/{resetFiltersEmptyState → reset-filters-empty-state}/filter-empty-data-illustration.tsx +2 -2
- package/src/test-results/ui-elements/{resetFiltersEmptyState → reset-filters-empty-state}/filter-empty-state.component.tsx +5 -6
- package/src/types.ts +20 -1
- package/translations/am.json +3 -4
- package/translations/ar.json +3 -4
- package/translations/ar_SY.json +3 -4
- package/translations/bn.json +3 -4
- package/translations/cs.json +119 -0
- package/translations/de.json +3 -4
- package/translations/en.json +3 -2
- package/translations/en_US.json +3 -4
- package/translations/es.json +3 -4
- package/translations/es_MX.json +3 -4
- package/translations/fr.json +5 -6
- package/translations/he.json +3 -4
- package/translations/hi.json +3 -4
- package/translations/hi_IN.json +3 -4
- package/translations/id.json +3 -4
- package/translations/it.json +3 -4
- package/translations/ka.json +3 -4
- package/translations/km.json +3 -4
- package/translations/ku.json +3 -4
- package/translations/ky.json +3 -4
- package/translations/lg.json +3 -4
- package/translations/ne.json +3 -4
- package/translations/pl.json +3 -4
- package/translations/pt.json +3 -4
- package/translations/pt_BR.json +3 -4
- package/translations/qu.json +3 -4
- package/translations/ro_RO.json +3 -4
- package/translations/ru_RU.json +3 -4
- package/translations/si.json +3 -4
- package/translations/sq.json +119 -0
- package/translations/sw.json +3 -4
- package/translations/sw_KE.json +3 -4
- package/translations/tr.json +3 -4
- package/translations/tr_TR.json +3 -4
- package/translations/uk.json +3 -4
- package/translations/uz.json +3 -4
- package/translations/uz@Latn.json +3 -4
- package/translations/uz_UZ.json +3 -4
- package/translations/vi.json +3 -4
- package/translations/zh.json +3 -4
- package/translations/zh_CN.json +3 -4
- package/translations/zh_TW.json +119 -0
- package/dist/1479.js +0 -1
- package/dist/1479.js.map +0 -1
- package/dist/2537.js +0 -1
- package/dist/2537.js.map +0 -1
- package/dist/4918.js +0 -1
- package/dist/4918.js.map +0 -1
- package/dist/5836.js +0 -2
- package/dist/5836.js.map +0 -1
- package/dist/6231.js.map +0 -1
- package/dist/7053.js +0 -2
- package/dist/7053.js.map +0 -1
- /package/dist/{7053.js.LICENSE.txt → 8307.js.LICENSE.txt} +0 -0
- /package/dist/{5836.js.LICENSE.txt → 8555.js.LICENSE.txt} +0 -0
- /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 {
|
|
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 {
|
|
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<{
|
|
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 =
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
56
|
-
|
|
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
|
-
|
|
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 };
|