@openmrs/esm-patient-tests-app 11.3.1-pre.9228 → 11.3.1-pre.9254
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 +5 -5
- package/dist/6231.js +1 -1
- package/dist/6231.js.map +1 -1
- package/dist/6301.js +1 -1
- package/dist/6301.js.map +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 +10 -10
- package/dist/routes.json +1 -1
- package/package.json +2 -2
- package/src/test-results/filter/filter-types.ts +19 -0
- package/src/test-results/grouped-timeline/grouped-timeline.test.tsx +49 -0
- package/src/test-results/grouped-timeline/reference-range-helpers.test.ts +272 -0
- package/src/test-results/grouped-timeline/reference-range-helpers.ts +112 -0
- package/src/test-results/grouped-timeline/timeline-data-group.component.tsx +10 -3
- package/src/test-results/grouped-timeline/useObstreeData.ts +68 -4
- package/src/test-results/individual-results-table/individual-results-table.component.tsx +14 -3
- package/src/test-results/individual-results-table/individual-results-table.test.tsx +46 -1
- package/src/test-results/individual-results-table-tablet/lab-set-panel.component.tsx +5 -1
- package/src/test-results/loadPatientTestData/usePatientResultsData.ts +3 -3
- package/src/test-results/trendline/trendline-resource.tsx +48 -7
- package/src/types.ts +11 -0
|
@@ -4,6 +4,7 @@ import useSWRInfinite from 'swr/infinite';
|
|
|
4
4
|
import { openmrsFetch, restBaseUrl, type FetchResponse } from '@openmrs/esm-framework';
|
|
5
5
|
import { usePatientChartStore, 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;
|
|
@@ -13,11 +14,28 @@ interface ObsTreeNode {
|
|
|
13
14
|
flatName?: string;
|
|
14
15
|
display: string;
|
|
15
16
|
hasData: boolean;
|
|
17
|
+
hiAbsolute?: number;
|
|
18
|
+
hiCritical?: number;
|
|
16
19
|
hiNormal?: number;
|
|
20
|
+
lowAbsolute?: number;
|
|
21
|
+
lowCritical?: number;
|
|
17
22
|
lowNormal?: number;
|
|
23
|
+
units?: string;
|
|
18
24
|
range?: string;
|
|
19
25
|
subSets: Array<ObsTreeNode>;
|
|
20
|
-
obs: Array<{
|
|
26
|
+
obs: Array<{
|
|
27
|
+
value: string;
|
|
28
|
+
interpretation?: OBSERVATION_INTERPRETATION;
|
|
29
|
+
obsDatetime?: string;
|
|
30
|
+
// Observation-level reference ranges (criteria-based)
|
|
31
|
+
// Note: Units are only at the concept/node level, not observation-level
|
|
32
|
+
hiAbsolute?: number;
|
|
33
|
+
hiCritical?: number;
|
|
34
|
+
hiNormal?: number;
|
|
35
|
+
lowAbsolute?: number;
|
|
36
|
+
lowCritical?: number;
|
|
37
|
+
lowNormal?: number;
|
|
38
|
+
}>;
|
|
21
39
|
}
|
|
22
40
|
|
|
23
41
|
const augmentObstreeData = (node: ObsTreeNode, prefix: string | undefined) => {
|
|
@@ -40,12 +58,58 @@ const augmentObstreeData = (node: ObsTreeNode, prefix: string | undefined) => {
|
|
|
40
58
|
outData.subSets = outData.subSets.map((subNode: ObsTreeNode) => augmentObstreeData(subNode, outData.flatName));
|
|
41
59
|
outData.hasData = outData.subSets.some((subNode: ObsTreeNode) => subNode.hasData);
|
|
42
60
|
}
|
|
61
|
+
// Format node-level range for display (using lowNormal/hiNormal)
|
|
43
62
|
if (exist(outData?.hiNormal, outData?.lowNormal)) {
|
|
44
|
-
outData.range =
|
|
63
|
+
outData.range = formatReferenceRange(
|
|
64
|
+
{
|
|
65
|
+
lowNormal: outData.lowNormal,
|
|
66
|
+
hiNormal: outData.hiNormal,
|
|
67
|
+
units: outData.units,
|
|
68
|
+
},
|
|
69
|
+
outData.units,
|
|
70
|
+
);
|
|
45
71
|
}
|
|
72
|
+
|
|
46
73
|
if (outData?.obs?.length) {
|
|
47
|
-
|
|
48
|
-
|
|
74
|
+
outData.obs = outData.obs.map((ob) => {
|
|
75
|
+
// Note: Units are only at the concept/node level, not observation-level
|
|
76
|
+
const observationRanges: ReferenceRanges | undefined =
|
|
77
|
+
ob.lowNormal !== undefined || ob.hiNormal !== undefined
|
|
78
|
+
? {
|
|
79
|
+
hiAbsolute: ob.hiAbsolute,
|
|
80
|
+
hiCritical: ob.hiCritical,
|
|
81
|
+
hiNormal: ob.hiNormal,
|
|
82
|
+
lowAbsolute: ob.lowAbsolute,
|
|
83
|
+
lowCritical: ob.lowCritical,
|
|
84
|
+
lowNormal: ob.lowNormal,
|
|
85
|
+
}
|
|
86
|
+
: undefined;
|
|
87
|
+
|
|
88
|
+
const nodeRanges: ReferenceRanges | undefined = {
|
|
89
|
+
hiAbsolute: outData.hiAbsolute,
|
|
90
|
+
hiCritical: outData.hiCritical,
|
|
91
|
+
hiNormal: outData.hiNormal,
|
|
92
|
+
lowAbsolute: outData.lowAbsolute,
|
|
93
|
+
lowCritical: outData.lowCritical,
|
|
94
|
+
lowNormal: outData.lowNormal,
|
|
95
|
+
units: outData.units,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const selectedRanges = selectReferenceRange(observationRanges, nodeRanges);
|
|
99
|
+
const assess = selectedRanges ? assessValue(selectedRanges) : assessValue(nodeRanges);
|
|
100
|
+
const interpretation = ob.interpretation ?? assess(ob.value);
|
|
101
|
+
|
|
102
|
+
// Always use node-level units since observation-level ranges don't have units
|
|
103
|
+
const displayRange = observationRanges
|
|
104
|
+
? formatReferenceRange(observationRanges, outData.units)
|
|
105
|
+
: outData.range || '--';
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
...ob,
|
|
109
|
+
interpretation,
|
|
110
|
+
range: displayRange,
|
|
111
|
+
};
|
|
112
|
+
});
|
|
49
113
|
outData.hasData = true;
|
|
50
114
|
}
|
|
51
115
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useCallback, useMemo } from 'react';
|
|
2
2
|
import classNames from 'classnames';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { rangeAlreadyHasUnits } from '../grouped-timeline/reference-range-helpers';
|
|
4
5
|
import {
|
|
5
6
|
DataTable,
|
|
6
7
|
DataTableSkeleton,
|
|
@@ -85,9 +86,19 @@ const IndividualResultsTable: React.FC<IndividualResultsTableProps> = ({ isLoadi
|
|
|
85
86
|
() =>
|
|
86
87
|
subRows?.entries.length &&
|
|
87
88
|
subRows.entries.map((row, i) => {
|
|
88
|
-
|
|
89
|
+
// Use observation-level range/units if available, otherwise fallback to node-level
|
|
90
|
+
// MappedObservation has range and units fields, but they may come from node-level
|
|
91
|
+
const displayRange = row.range ?? '';
|
|
92
|
+
const displayUnits = row.units ?? '';
|
|
89
93
|
const isString = isNaN(parseFloat(row.value));
|
|
90
94
|
|
|
95
|
+
// Check if range already includes units to avoid duplication
|
|
96
|
+
// formatReferenceRange includes units, so if range has units, don't append again
|
|
97
|
+
const hasUnits = rangeAlreadyHasUnits(displayRange, displayUnits);
|
|
98
|
+
const referenceRangeDisplay = hasUnits
|
|
99
|
+
? displayRange
|
|
100
|
+
: `${displayRange || '--'} ${displayUnits || ''}`.trim() || '--';
|
|
101
|
+
|
|
91
102
|
return {
|
|
92
103
|
...row,
|
|
93
104
|
id: `${i}-${index}`,
|
|
@@ -106,10 +117,10 @@ const IndividualResultsTable: React.FC<IndividualResultsTableProps> = ({ isLoadi
|
|
|
106
117
|
</span>
|
|
107
118
|
),
|
|
108
119
|
value: {
|
|
109
|
-
value: `${row.value} ${
|
|
120
|
+
value: `${row.value} ${displayUnits}`,
|
|
110
121
|
interpretation: row?.interpretation,
|
|
111
122
|
},
|
|
112
|
-
referenceRange:
|
|
123
|
+
referenceRange: referenceRangeDisplay,
|
|
113
124
|
};
|
|
114
125
|
}),
|
|
115
126
|
[index, subRows, launchResultsDialog],
|
|
@@ -21,10 +21,41 @@ describe('IndividualResultsTable', () => {
|
|
|
21
21
|
units: 'copies/ml',
|
|
22
22
|
flatName: 'HIV viral load-HIV viral load',
|
|
23
23
|
hasData: true,
|
|
24
|
+
range: '0 – 50', // Node-level range
|
|
25
|
+
lowNormal: 0,
|
|
26
|
+
hiNormal: 50,
|
|
24
27
|
},
|
|
25
28
|
],
|
|
26
29
|
} as GroupedObservation;
|
|
27
30
|
|
|
31
|
+
const mockSubRowsWithObservationRange = {
|
|
32
|
+
key: 'Alkaline phosphatase',
|
|
33
|
+
date: '2024-10-15',
|
|
34
|
+
flatName: 'Alkaline phosphatase',
|
|
35
|
+
entries: [
|
|
36
|
+
{
|
|
37
|
+
obsDatetime: '2024-10-15 03:20:19.0',
|
|
38
|
+
value: '15',
|
|
39
|
+
interpretation: 'CRITICALLY_LOW',
|
|
40
|
+
key: 'Alkaline phosphatase',
|
|
41
|
+
datatype: 'Numeric',
|
|
42
|
+
lowAbsolute: 0,
|
|
43
|
+
display: 'Alkaline phosphatase',
|
|
44
|
+
conceptUuid: '785AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
45
|
+
units: 'U/L',
|
|
46
|
+
flatName: 'Alkaline phosphatase',
|
|
47
|
+
hasData: true,
|
|
48
|
+
range: '35 – 147', // Observation-level range (different from node-level)
|
|
49
|
+
lowNormal: 35,
|
|
50
|
+
hiNormal: 147,
|
|
51
|
+
lowCritical: 25,
|
|
52
|
+
hiCritical: 200,
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
range: '0 – 270', // Node-level range (fallback)
|
|
56
|
+
units: 'U/L',
|
|
57
|
+
} as GroupedObservation;
|
|
58
|
+
|
|
28
59
|
const mockEmptySubRows = {
|
|
29
60
|
key: 'HIV viral load',
|
|
30
61
|
date: '2024-10-15',
|
|
@@ -45,6 +76,20 @@ describe('IndividualResultsTable', () => {
|
|
|
45
76
|
expect(screen.getByText(/15-Oct-2024/i)).toBeInTheDocument();
|
|
46
77
|
expect(screen.getByText(/test name/i)).toBeInTheDocument();
|
|
47
78
|
expect(screen.getByText(/reference range/i)).toBeInTheDocument();
|
|
48
|
-
expect(screen.getByRole('row', { name: /hiv viral load 45 copies\/ml
|
|
79
|
+
expect(screen.getByRole('row', { name: /hiv viral load 45 copies\/ml 0 – 50 copies\/ml/i })).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('uses observation-level range when available', () => {
|
|
83
|
+
render(
|
|
84
|
+
<IndividualResultsTable
|
|
85
|
+
isLoading={false}
|
|
86
|
+
subRows={mockSubRowsWithObservationRange}
|
|
87
|
+
index={0}
|
|
88
|
+
title={'Alkaline phosphatase'}
|
|
89
|
+
/>,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Should display observation-level range (35 – 147) not node-level (0 – 270)
|
|
93
|
+
expect(screen.getByRole('row', { name: /alkaline phosphatase 15 u\/l 35 – 147 u\/l/i })).toBeInTheDocument();
|
|
49
94
|
});
|
|
50
95
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useMemo } from 'react';
|
|
2
2
|
import classNames from 'classnames';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { rangeAlreadyHasUnits } from '../grouped-timeline/reference-range-helpers';
|
|
4
5
|
import {
|
|
5
6
|
DataTable,
|
|
6
7
|
TableContainer,
|
|
@@ -70,7 +71,10 @@ const LabSetPanel: React.FC<LabSetPanelProps> = ({ panel, activePanel, setActive
|
|
|
70
71
|
hasRange
|
|
71
72
|
? panel.entries.map((test) => {
|
|
72
73
|
const units = test.units ?? '';
|
|
73
|
-
|
|
74
|
+
// Check if range already includes units to avoid duplication
|
|
75
|
+
// formatReferenceRange includes units, so if range has units, don't append again
|
|
76
|
+
const hasUnits = rangeAlreadyHasUnits(test.range, units);
|
|
77
|
+
const range = hasUnits ? test.range : test.range ? `${test.range} ${units}` : '--';
|
|
74
78
|
return {
|
|
75
79
|
id: test.conceptUuid,
|
|
76
80
|
testName: test.display,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
2
|
import { type PatientData } from '@openmrs/esm-patient-common-lib';
|
|
3
3
|
import loadPatientData from './loadPatientData';
|
|
4
4
|
|
|
@@ -9,13 +9,13 @@ type LoadingState = {
|
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
const usePatientResultsData = (patientUuid: string): LoadingState => {
|
|
12
|
-
const [state, setState] =
|
|
12
|
+
const [state, setState] = useState<LoadingState>({
|
|
13
13
|
sortedObs: {},
|
|
14
14
|
loaded: false,
|
|
15
15
|
error: undefined,
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
useEffect(() => {
|
|
19
19
|
let unmounted = false;
|
|
20
20
|
if (patientUuid) {
|
|
21
21
|
const [data, reloadedDataPromise] = loadPatientData(patientUuid);
|
|
@@ -3,6 +3,11 @@ import useSWR from 'swr';
|
|
|
3
3
|
import { type FetchResponse, openmrsFetch, showSnackbar, restBaseUrl } from '@openmrs/esm-framework';
|
|
4
4
|
import { assessValue } from '../loadPatientTestData/helpers';
|
|
5
5
|
import { type TreeNode } from '../filter/filter-types';
|
|
6
|
+
import {
|
|
7
|
+
selectReferenceRange,
|
|
8
|
+
formatReferenceRange,
|
|
9
|
+
type ReferenceRanges,
|
|
10
|
+
} from '../grouped-timeline/reference-range-helpers';
|
|
6
11
|
|
|
7
12
|
function computeTrendlineData(treeNode: TreeNode): Array<TreeNode> {
|
|
8
13
|
const tests: Array<TreeNode> = [];
|
|
@@ -11,14 +16,50 @@ function computeTrendlineData(treeNode: TreeNode): Array<TreeNode> {
|
|
|
11
16
|
}
|
|
12
17
|
treeNode?.subSets.forEach((subNode) => {
|
|
13
18
|
if ((subNode as TreeNode)?.obs) {
|
|
14
|
-
const
|
|
15
|
-
|
|
19
|
+
const subTreeNode = subNode as TreeNode;
|
|
20
|
+
// Node-level reference ranges for trendline (aggregate view)
|
|
21
|
+
const nodeRanges: ReferenceRanges = {
|
|
22
|
+
hiAbsolute: subTreeNode.hiAbsolute,
|
|
23
|
+
hiCritical: subTreeNode.hiCritical,
|
|
24
|
+
hiNormal: subTreeNode.hiNormal,
|
|
25
|
+
lowAbsolute: subTreeNode.lowAbsolute,
|
|
26
|
+
lowCritical: subTreeNode.lowCritical,
|
|
27
|
+
lowNormal: subTreeNode.lowNormal,
|
|
28
|
+
units: subTreeNode.units,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const range = formatReferenceRange(nodeRanges, subTreeNode.units);
|
|
32
|
+
|
|
33
|
+
const processedObs = subTreeNode.obs.map((ob) => {
|
|
34
|
+
// Note: Units are only at the concept/node level, not observation-level
|
|
35
|
+
const observationRanges: ReferenceRanges | undefined =
|
|
36
|
+
ob.lowNormal !== undefined || ob.hiNormal !== undefined
|
|
37
|
+
? {
|
|
38
|
+
hiAbsolute: ob.hiAbsolute,
|
|
39
|
+
hiCritical: ob.hiCritical,
|
|
40
|
+
hiNormal: ob.hiNormal,
|
|
41
|
+
lowAbsolute: ob.lowAbsolute,
|
|
42
|
+
lowCritical: ob.lowCritical,
|
|
43
|
+
lowNormal: ob.lowNormal,
|
|
44
|
+
}
|
|
45
|
+
: undefined;
|
|
46
|
+
|
|
47
|
+
const selectedRanges = selectReferenceRange(observationRanges, nodeRanges);
|
|
48
|
+
const assess = selectedRanges ? assessValue(selectedRanges) : assessValue(nodeRanges);
|
|
49
|
+
const interpretation = ob.interpretation ?? assess(ob.value);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
...ob,
|
|
53
|
+
interpretation,
|
|
54
|
+
lowNormal: ob.lowNormal,
|
|
55
|
+
hiNormal: ob.hiNormal,
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
16
59
|
tests.push({
|
|
17
|
-
...
|
|
18
|
-
range
|
|
19
|
-
obs:
|
|
20
|
-
return { ...ob, interpretation: ob.interpretation ?? assess(ob.value) };
|
|
21
|
-
}),
|
|
60
|
+
...subTreeNode,
|
|
61
|
+
range,
|
|
62
|
+
obs: processedObs,
|
|
22
63
|
});
|
|
23
64
|
} else if (subNode?.subSets) {
|
|
24
65
|
const subTreesTests = computeTrendlineData(subNode as TreeNode); // recursion
|
package/src/types.ts
CHANGED
|
@@ -142,6 +142,15 @@ export type Observation = {
|
|
|
142
142
|
obsDatetime: string;
|
|
143
143
|
value: string;
|
|
144
144
|
interpretation: OBSERVATION_INTERPRETATION;
|
|
145
|
+
// Observation-level reference ranges (criteria-based)
|
|
146
|
+
// Note: Units are only at the concept/node level (TestResult.units), not observation-level
|
|
147
|
+
hiAbsolute?: number;
|
|
148
|
+
hiCritical?: number;
|
|
149
|
+
hiNormal?: number;
|
|
150
|
+
lowAbsolute?: number;
|
|
151
|
+
lowCritical?: number;
|
|
152
|
+
lowNormal?: number;
|
|
153
|
+
range?: string; // Formatted range string for display
|
|
145
154
|
};
|
|
146
155
|
|
|
147
156
|
export type TestResult = {
|
|
@@ -168,6 +177,8 @@ export type MappedObservation = {
|
|
|
168
177
|
units: string;
|
|
169
178
|
lowCritical: number;
|
|
170
179
|
hiNormal: number;
|
|
180
|
+
hiAbsolute?: number;
|
|
181
|
+
hiCritical?: number;
|
|
171
182
|
flatName: string;
|
|
172
183
|
hasData: boolean;
|
|
173
184
|
range: string;
|