@openmrs/esm-patient-tests-app 12.2.0 → 12.2.1
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 +4 -4
- package/dist/2315.js +1 -1
- package/dist/2315.js.map +1 -1
- package/dist/main.js +6 -6
- 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 +7 -7
- package/dist/routes.json +1 -1
- package/package.json +2 -2
- package/src/test-results/filter/filter-context.test.tsx +240 -0
- package/src/test-results/filter/filter-context.tsx +88 -64
- package/src/test-results/filter/filter-reducer.test.ts +51 -0
- package/src/test-results/filter/filter-reducer.ts +41 -11
- package/src/test-results/grouped-timeline/grouped-timeline.component.tsx +4 -3
- package/src/test-results/grouped-timeline/grouped-timeline.test.tsx +55 -0
- package/src/test-results/tree-view/tree-view.component.tsx +9 -4
- package/src/test-results/tree-view/tree-view.test.tsx +57 -0
- package/src/types.ts +2 -0
|
@@ -4,11 +4,12 @@ import { type MappedObservation, type TestResult, type GroupedObservation, type
|
|
|
4
4
|
import {
|
|
5
5
|
ReducerActionType,
|
|
6
6
|
type FilterContextProps,
|
|
7
|
+
type ObservationData,
|
|
7
8
|
type ReducerState,
|
|
8
9
|
type TimelineData,
|
|
9
10
|
type TreeNode,
|
|
10
11
|
} from './filter-types';
|
|
11
|
-
import reducer from './filter-reducer';
|
|
12
|
+
import reducer, { mergeObsMultisetMax } from './filter-reducer';
|
|
12
13
|
|
|
13
14
|
function parseTime(sortedTimes: Array<string>) {
|
|
14
15
|
const yearColumns: Array<{ year: string; size: number }> = [],
|
|
@@ -45,6 +46,16 @@ function deriveGroupKey(flatName: string): string {
|
|
|
45
46
|
return flatNameParts.length >= 2 ? flatNameParts[1] : flatNameParts[0];
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
// A rendered branch: one concept under one rendered panel, after collapsing the
|
|
50
|
+
// duplicate branches the obstree backend can return for the same concept (e.g.
|
|
51
|
+
// one branch per order placed for a test).
|
|
52
|
+
interface NormalizedBranch {
|
|
53
|
+
test: TestResult;
|
|
54
|
+
stateKey: string;
|
|
55
|
+
flatNames: Array<string>;
|
|
56
|
+
obs: Array<ObservationData>;
|
|
57
|
+
}
|
|
58
|
+
|
|
48
59
|
const initialState: ReducerState = {
|
|
49
60
|
checkboxes: {},
|
|
50
61
|
parents: {},
|
|
@@ -99,6 +110,44 @@ const FilterProvider = ({ roots, isLoading, children }: FilterProviderProps) =>
|
|
|
99
110
|
|
|
100
111
|
const someChecked = Boolean(activeTests.length);
|
|
101
112
|
|
|
113
|
+
// Normalize rendered branches by the concept's identity within its rendered
|
|
114
|
+
// panel. The obstree backend can return the same concept under several
|
|
115
|
+
// branches (e.g. one branch per order), each carrying a copy of the same obs
|
|
116
|
+
// list. Duplicate branches within one panel merge their obs with the same
|
|
117
|
+
// multiset-max union the reducer applies to same-flatName duplicates, so
|
|
118
|
+
// copied lists collapse while equal obs within one list survive as distinct
|
|
119
|
+
// results. A concept that legitimately renders under two different panels
|
|
120
|
+
// stays as two branches. tableData, timelineData, and both result counts
|
|
121
|
+
// derive from this list, keeping the header count aligned with rendered rows.
|
|
122
|
+
const normalizedTests = useMemo<NormalizedBranch[]>(() => {
|
|
123
|
+
const branchesByIdentity = new Map<string, NormalizedBranch>();
|
|
124
|
+
|
|
125
|
+
for (const key in state.tests) {
|
|
126
|
+
const test = state.tests[key] as TestResult;
|
|
127
|
+
if (!test.obs || !Array.isArray(test.obs) || test.obs.length === 0) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const groupKey = deriveGroupKey(test.flatName);
|
|
132
|
+
const identity = `${groupKey}_${test.conceptUuid ?? test.flatName}`;
|
|
133
|
+
const existing = branchesByIdentity.get(identity);
|
|
134
|
+
|
|
135
|
+
if (existing) {
|
|
136
|
+
existing.flatNames.push(test.flatName);
|
|
137
|
+
existing.obs = mergeObsMultisetMax(existing.obs, test.obs);
|
|
138
|
+
} else {
|
|
139
|
+
branchesByIdentity.set(identity, {
|
|
140
|
+
test,
|
|
141
|
+
stateKey: key,
|
|
142
|
+
flatNames: [test.flatName],
|
|
143
|
+
obs: [...test.obs],
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return [...branchesByIdentity.values()];
|
|
149
|
+
}, [state.tests]);
|
|
150
|
+
|
|
102
151
|
const timelineData: TimelineData = useMemo(() => {
|
|
103
152
|
if (!state?.tests) {
|
|
104
153
|
return {
|
|
@@ -107,27 +156,25 @@ const FilterProvider = ({ roots, isLoading, children }: FilterProviderProps) =>
|
|
|
107
156
|
};
|
|
108
157
|
}
|
|
109
158
|
|
|
110
|
-
const tests
|
|
111
|
-
?
|
|
112
|
-
:
|
|
159
|
+
const tests = activeTests.length
|
|
160
|
+
? normalizedTests.filter((branch) => branch.flatNames.some((flatName) => activeTests.includes(flatName)))
|
|
161
|
+
: normalizedTests;
|
|
113
162
|
|
|
114
163
|
const allTimes = [
|
|
115
164
|
...new Set(
|
|
116
|
-
|
|
117
|
-
.
|
|
118
|
-
.
|
|
119
|
-
.
|
|
165
|
+
tests
|
|
166
|
+
.map(({ obs }) => obs.map((entry) => entry.obsDatetime))
|
|
167
|
+
.flat()
|
|
168
|
+
.filter(Boolean),
|
|
120
169
|
),
|
|
121
170
|
];
|
|
122
171
|
|
|
123
172
|
allTimes.sort((a, b) => new Date(b).getTime() - new Date(a).getTime());
|
|
124
173
|
|
|
125
174
|
const rows = [];
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
rows.push({ ...testData, entries: newEntries });
|
|
130
|
-
}
|
|
175
|
+
tests.forEach(({ test, stateKey, flatNames, obs }) => {
|
|
176
|
+
const newEntries = allTimes.map((time) => obs.find((entry) => entry.obsDatetime === time));
|
|
177
|
+
rows.push({ ...test, key: stateKey, flatNames, obs, entries: newEntries });
|
|
131
178
|
});
|
|
132
179
|
|
|
133
180
|
const panelName = 'timeline';
|
|
@@ -135,39 +182,22 @@ const FilterProvider = ({ roots, isLoading, children }: FilterProviderProps) =>
|
|
|
135
182
|
data: { parsedTime: parseTime(allTimes), rowData: rows, panelName },
|
|
136
183
|
loaded: true,
|
|
137
184
|
};
|
|
138
|
-
}, [activeTests, state.tests]);
|
|
185
|
+
}, [activeTests, normalizedTests, state.tests]);
|
|
139
186
|
|
|
140
187
|
const tableData = useMemo<GroupedObservation[]>(() => {
|
|
141
188
|
const flattenedObs: Observation[] = [];
|
|
142
|
-
const seenTests = new Set<string>();
|
|
143
189
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
// rows, while collapsing true duplicates within one panel.
|
|
156
|
-
const groupKey = deriveGroupKey(test.flatName);
|
|
157
|
-
const testKey = `${groupKey}_${test.conceptUuid ?? test.flatName}_${obs.obsDatetime}_${obs.value}`;
|
|
158
|
-
|
|
159
|
-
if (!seenTests.has(testKey)) {
|
|
160
|
-
seenTests.add(testKey);
|
|
161
|
-
const flattenedEntry = {
|
|
162
|
-
...test,
|
|
163
|
-
...obs,
|
|
164
|
-
key: key,
|
|
165
|
-
};
|
|
166
|
-
flattenedObs.push(flattenedEntry);
|
|
167
|
-
}
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
}
|
|
190
|
+
normalizedTests.forEach(({ test, stateKey, flatNames, obs }) => {
|
|
191
|
+
obs.forEach((ob) => {
|
|
192
|
+
const flattenedEntry = {
|
|
193
|
+
...test,
|
|
194
|
+
...ob,
|
|
195
|
+
key: stateKey,
|
|
196
|
+
flatNames,
|
|
197
|
+
};
|
|
198
|
+
flattenedObs.push(flattenedEntry);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
171
201
|
|
|
172
202
|
const groupedObs: Record<string, GroupedObservation> = {};
|
|
173
203
|
|
|
@@ -194,7 +224,7 @@ const FilterProvider = ({ roots, isLoading, children }: FilterProviderProps) =>
|
|
|
194
224
|
);
|
|
195
225
|
|
|
196
226
|
return resultArray;
|
|
197
|
-
}, [
|
|
227
|
+
}, [normalizedTests]);
|
|
198
228
|
|
|
199
229
|
useEffect(() => {
|
|
200
230
|
if (roots.length) {
|
|
@@ -202,33 +232,27 @@ const FilterProvider = ({ roots, isLoading, children }: FilterProviderProps) =>
|
|
|
202
232
|
}
|
|
203
233
|
}, [actions, roots]);
|
|
204
234
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
return count;
|
|
214
|
-
}, [state.tests]);
|
|
235
|
+
// Both counts derive from the same normalized branches that tableData
|
|
236
|
+
// renders, so the header count stays aligned with the visible rows.
|
|
237
|
+
const totalResultsCount: number = useMemo(
|
|
238
|
+
() => normalizedTests.reduce((count, branch) => count + branch.obs.length, 0),
|
|
239
|
+
[normalizedTests],
|
|
240
|
+
);
|
|
215
241
|
|
|
216
242
|
const filteredResultsCount: number = useMemo(() => {
|
|
217
243
|
if (!someChecked) {
|
|
218
244
|
return totalResultsCount; // No filters applied, show total
|
|
219
245
|
}
|
|
220
246
|
|
|
221
|
-
// Count only the tests that are currently selected
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
count
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return count;
|
|
231
|
-
}, [someChecked, activeTests, state.tests, totalResultsCount]);
|
|
247
|
+
// Count only the tests that are currently selected. Checkboxes are keyed by
|
|
248
|
+
// flatName, so a normalized branch counts if any of its contributing
|
|
249
|
+
// flatNames is checked.
|
|
250
|
+
return normalizedTests.reduce(
|
|
251
|
+
(count, branch) =>
|
|
252
|
+
branch.flatNames.some((flatName) => activeTests.includes(flatName)) ? count + branch.obs.length : count,
|
|
253
|
+
0,
|
|
254
|
+
);
|
|
255
|
+
}, [someChecked, activeTests, normalizedTests, totalResultsCount]);
|
|
232
256
|
|
|
233
257
|
return (
|
|
234
258
|
<FilterContext.Provider
|
|
@@ -587,4 +587,55 @@ describe('filterReducer', () => {
|
|
|
587
587
|
expect(newState).toEqual(currentState);
|
|
588
588
|
});
|
|
589
589
|
});
|
|
590
|
+
|
|
591
|
+
describe('Merging same-flatName duplicates across trees', () => {
|
|
592
|
+
const obs = (value: string) =>
|
|
593
|
+
({ obsDatetime: '2024-11-04T05:48:00.000Z', value, interpretation: 'LOW' }) as TreeNode['obs'][number];
|
|
594
|
+
|
|
595
|
+
const treeWithPlatelets = (rootFlatName: string, plateletObs: TreeNode['obs']): TreeNode => ({
|
|
596
|
+
flatName: rootFlatName,
|
|
597
|
+
display: rootFlatName,
|
|
598
|
+
hasData: true,
|
|
599
|
+
subSets: [
|
|
600
|
+
{
|
|
601
|
+
// Both roots resolve to the same leaf flatName, mirroring the
|
|
602
|
+
// augmentation that drops the Bloodwork prefix for overlapping roots.
|
|
603
|
+
flatName: 'Hematology: Platelets',
|
|
604
|
+
display: 'Platelets',
|
|
605
|
+
obs: plateletObs,
|
|
606
|
+
hasData: true,
|
|
607
|
+
},
|
|
608
|
+
],
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('collapses copies of the same obs list instead of concatenating them', () => {
|
|
612
|
+
const trees = [treeWithPlatelets('Hematology', [obs('56')]), treeWithPlatelets('Bloodwork', [obs('56')])];
|
|
613
|
+
|
|
614
|
+
const newState = reducer(initialState, { type: ReducerActionType.INITIALIZE, trees });
|
|
615
|
+
|
|
616
|
+
expect(newState.tests['Hematology: Platelets'].obs).toHaveLength(1);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it('keeps genuinely repeated equal obs that appear within a single list', () => {
|
|
620
|
+
const trees = [
|
|
621
|
+
treeWithPlatelets('Hematology', [obs('56'), obs('56')]),
|
|
622
|
+
treeWithPlatelets('Bloodwork', [obs('56')]),
|
|
623
|
+
];
|
|
624
|
+
|
|
625
|
+
const newState = reducer(initialState, { type: ReducerActionType.INITIALIZE, trees });
|
|
626
|
+
|
|
627
|
+
expect(newState.tests['Hematology: Platelets'].obs).toHaveLength(2);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('does not mutate the source trees and stays idempotent across re-initialization', () => {
|
|
631
|
+
const trees = [treeWithPlatelets('Hematology', [obs('56')]), treeWithPlatelets('Bloodwork', [obs('56')])];
|
|
632
|
+
|
|
633
|
+
const firstState = reducer(initialState, { type: ReducerActionType.INITIALIZE, trees });
|
|
634
|
+
const secondState = reducer(firstState, { type: ReducerActionType.INITIALIZE, trees });
|
|
635
|
+
|
|
636
|
+
expect(trees[0].subSets[0].obs).toHaveLength(1);
|
|
637
|
+
expect(trees[1].subSets[0].obs).toHaveLength(1);
|
|
638
|
+
expect(secondState.tests['Hematology: Platelets'].obs).toHaveLength(1);
|
|
639
|
+
});
|
|
640
|
+
});
|
|
590
641
|
});
|
|
@@ -1,12 +1,43 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ReducerActionType,
|
|
3
3
|
type LowestNode,
|
|
4
|
+
type ObservationData,
|
|
4
5
|
type ReducerAction,
|
|
5
6
|
type ReducerState,
|
|
6
7
|
type TreeNode,
|
|
7
8
|
type TreeParents,
|
|
8
9
|
} from './filter-types';
|
|
9
10
|
|
|
11
|
+
// Merge two copies of a concept's obs list as a multiset union: for each
|
|
12
|
+
// (obsDatetime, value) identity, keep the highest count seen in either list.
|
|
13
|
+
// The obstree backend serializes a concept's full obs list once per branch, so
|
|
14
|
+
// equal obs within one list are genuinely distinct results, while equal lists
|
|
15
|
+
// across branches are copies of the same results. obsDatetime + value is the
|
|
16
|
+
// best identity available until the backend exposes obs uuids; it cannot
|
|
17
|
+
// distinguish a copy from a real duplicate that only ever appears in separate
|
|
18
|
+
// lists, which is a documented limitation rather than a bug to fix here.
|
|
19
|
+
export function mergeObsMultisetMax(
|
|
20
|
+
existing: Array<ObservationData>,
|
|
21
|
+
incoming: Array<ObservationData>,
|
|
22
|
+
): Array<ObservationData> {
|
|
23
|
+
const merged = [...existing];
|
|
24
|
+
const existingCounts = new Map<string, number>();
|
|
25
|
+
for (const obs of existing) {
|
|
26
|
+
const id = `${obs.obsDatetime}|${obs.value}`;
|
|
27
|
+
existingCounts.set(id, (existingCounts.get(id) ?? 0) + 1);
|
|
28
|
+
}
|
|
29
|
+
const incomingCounts = new Map<string, number>();
|
|
30
|
+
for (const obs of incoming) {
|
|
31
|
+
const id = `${obs.obsDatetime}|${obs.value}`;
|
|
32
|
+
const seen = (incomingCounts.get(id) ?? 0) + 1;
|
|
33
|
+
incomingCounts.set(id, seen);
|
|
34
|
+
if (seen > (existingCounts.get(id) ?? 0)) {
|
|
35
|
+
merged.push(obs);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return merged;
|
|
39
|
+
}
|
|
40
|
+
|
|
10
41
|
const ROOT_PREFIX = '';
|
|
11
42
|
|
|
12
43
|
export const getName = (prefix: string | undefined, name: string): string => {
|
|
@@ -95,19 +126,18 @@ function reducer(state: ReducerState, action: ReducerAction): ReducerState {
|
|
|
95
126
|
lowestParents = [...lowestParents, ...newLowestParents];
|
|
96
127
|
});
|
|
97
128
|
|
|
98
|
-
//
|
|
129
|
+
// Tests reached through overlapping roots can resolve to the same flatName,
|
|
130
|
+
// each branch carrying a copy of the same concept's obs list. Merge the
|
|
131
|
+
// copies with a multiset-max union rather than concatenating, and copy the
|
|
132
|
+
// node so the SWR-cached trees are never mutated and INITIALIZE stays
|
|
133
|
+
// idempotent across re-dispatches.
|
|
99
134
|
const flatTests: Record<string, TreeNode> = {};
|
|
100
135
|
tests.forEach(([key, test]) => {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
flatTests[key].obs.push(...test.obs);
|
|
108
|
-
}
|
|
109
|
-
} else {
|
|
110
|
-
flatTests[key] = test;
|
|
136
|
+
const existing = flatTests[key];
|
|
137
|
+
if (!existing) {
|
|
138
|
+
flatTests[key] = { ...test, obs: test.obs ? [...test.obs] : test.obs };
|
|
139
|
+
} else if (test.obs && Array.isArray(test.obs)) {
|
|
140
|
+
existing.obs = mergeObsMultisetMax(existing.obs ?? [], test.obs);
|
|
111
141
|
}
|
|
112
142
|
});
|
|
113
143
|
|
|
@@ -23,9 +23,10 @@ export const GroupedTimeline: React.FC<{ patientUuid: string }> = ({ patientUuid
|
|
|
23
23
|
return (
|
|
24
24
|
<div className={styles.timelineDataContainer}>
|
|
25
25
|
{tableData.map((panel, index) => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
const panelTestNames = new Set(
|
|
27
|
+
panel.entries.flatMap((entry) => [...(entry.flatNames ?? []), entry.flatName]),
|
|
28
|
+
);
|
|
29
|
+
const subRows = rowData?.filter((row: { flatName: string }) => panelTestNames.has(row.flatName));
|
|
29
30
|
|
|
30
31
|
return (
|
|
31
32
|
subRows?.length > 0 && (
|
|
@@ -141,6 +141,61 @@ describe('GroupedTimeline', () => {
|
|
|
141
141
|
// expect(screen.queryByText('Total bilirubin')).not.toBeInTheDocument();
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
+
it('keeps timeline rows visible when filtering by a collapsed branch alias', () => {
|
|
145
|
+
const primaryFlatName = 'Hematology-Complete blood count-Platelets';
|
|
146
|
+
const selectedFlatName = 'Chemistry-Complete blood count-Platelets';
|
|
147
|
+
const obs = {
|
|
148
|
+
obsDatetime: '2024-11-04T05:48:00.000Z',
|
|
149
|
+
value: '56.0',
|
|
150
|
+
interpretation: 'LOW' as const,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
renderGroupedTimeline({
|
|
154
|
+
...mockFilterContext,
|
|
155
|
+
activeTests: [selectedFlatName],
|
|
156
|
+
someChecked: true,
|
|
157
|
+
tableData: [
|
|
158
|
+
{
|
|
159
|
+
key: 'Complete blood count',
|
|
160
|
+
date: '2024-11-04',
|
|
161
|
+
flatName: primaryFlatName,
|
|
162
|
+
entries: [
|
|
163
|
+
{
|
|
164
|
+
...obs,
|
|
165
|
+
key: primaryFlatName,
|
|
166
|
+
display: 'Platelets',
|
|
167
|
+
flatName: primaryFlatName,
|
|
168
|
+
flatNames: [primaryFlatName, selectedFlatName],
|
|
169
|
+
hasData: true,
|
|
170
|
+
range: '',
|
|
171
|
+
conceptUuid: '729AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
timelineData: {
|
|
177
|
+
data: {
|
|
178
|
+
parsedTime: mockFilterContext.timelineData.data.parsedTime,
|
|
179
|
+
panelName: 'timeline',
|
|
180
|
+
rowData: [
|
|
181
|
+
{
|
|
182
|
+
display: 'Platelets',
|
|
183
|
+
flatName: selectedFlatName,
|
|
184
|
+
conceptUuid: '729AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
185
|
+
hasData: true,
|
|
186
|
+
obs: [obs],
|
|
187
|
+
entries: [obs],
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
loaded: true,
|
|
192
|
+
},
|
|
193
|
+
} as FilterContextProps);
|
|
194
|
+
|
|
195
|
+
expect(screen.getByText('Platelets')).toBeInTheDocument();
|
|
196
|
+
expect(screen.getByText('56.0')).toBeInTheDocument();
|
|
197
|
+
});
|
|
198
|
+
|
|
144
199
|
it('correctly applies interpretation styling to results', () => {
|
|
145
200
|
const contextWithInterpretations = {
|
|
146
201
|
...mockFilterContext,
|
|
@@ -28,6 +28,11 @@ const GroupedPanelsTables: React.FC<{ patientUuid: string; className: string; lo
|
|
|
28
28
|
const { checkboxes, someChecked, tableData } = useContext(FilterContext);
|
|
29
29
|
const selectedCheckboxes = Object.keys(checkboxes).filter((key) => checkboxes[key]);
|
|
30
30
|
|
|
31
|
+
// An entry can represent several collapsed branches; match a checkbox against
|
|
32
|
+
// every flatName that contributed to it, not just the one it carries.
|
|
33
|
+
const entryMatchesCheckbox = (entry: GroupedObservation['entries'][number], selectedKey: string) =>
|
|
34
|
+
(entry.flatNames ?? [entry.flatName]).includes(selectedKey) || entry.key === selectedKey;
|
|
35
|
+
|
|
31
36
|
const tableFilteredSubRows = useMemo(
|
|
32
37
|
() =>
|
|
33
38
|
tableData
|
|
@@ -36,7 +41,9 @@ const GroupedPanelsTables: React.FC<{ patientUuid: string; className: string; lo
|
|
|
36
41
|
!someChecked ||
|
|
37
42
|
(row.entries &&
|
|
38
43
|
Array.isArray(row.entries) &&
|
|
39
|
-
row.entries.some((entry) =>
|
|
44
|
+
row.entries.some((entry) =>
|
|
45
|
+
selectedCheckboxes.some((selectedKey) => entryMatchesCheckbox(entry, selectedKey)),
|
|
46
|
+
)),
|
|
40
47
|
)
|
|
41
48
|
.map((subRows: GroupedObservation, index) => {
|
|
42
49
|
return {
|
|
@@ -46,9 +53,7 @@ const GroupedPanelsTables: React.FC<{ patientUuid: string; className: string; lo
|
|
|
46
53
|
? subRows.entries.filter(
|
|
47
54
|
(entry) =>
|
|
48
55
|
!someChecked ||
|
|
49
|
-
selectedCheckboxes.some(
|
|
50
|
-
(selectedKey) => entry.flatName === selectedKey || entry.key === selectedKey,
|
|
51
|
-
),
|
|
56
|
+
selectedCheckboxes.some((selectedKey) => entryMatchesCheckbox(entry, selectedKey)),
|
|
52
57
|
)
|
|
53
58
|
: [],
|
|
54
59
|
};
|
|
@@ -108,6 +108,63 @@ describe('TreeView', () => {
|
|
|
108
108
|
expect(screen.getAllByText('Hematocrit').length).toBeGreaterThan(0);
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
describe('Checkbox filtering of collapsed duplicate branches', () => {
|
|
112
|
+
const sharedConceptUuid = '729AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
|
113
|
+
|
|
114
|
+
const plateletBranch = (rootName: string) => ({
|
|
115
|
+
display: rootName,
|
|
116
|
+
flatName: rootName,
|
|
117
|
+
hasData: true,
|
|
118
|
+
subSets: [
|
|
119
|
+
{
|
|
120
|
+
display: 'Complete blood count',
|
|
121
|
+
flatName: `${rootName}-Complete blood count`,
|
|
122
|
+
hasData: true,
|
|
123
|
+
subSets: [
|
|
124
|
+
{
|
|
125
|
+
display: 'Platelets',
|
|
126
|
+
flatName: `${rootName}-Complete blood count-Platelets`,
|
|
127
|
+
conceptUuid: sharedConceptUuid,
|
|
128
|
+
hasData: true,
|
|
129
|
+
obs: [{ obsDatetime: '2024-11-04T05:48:00.000Z', value: '56.0', interpretation: 'LOW' as const }],
|
|
130
|
+
subSets: [],
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('keeps the collapsed row visible when filtering by its second contributing flatName', async () => {
|
|
138
|
+
const user = userEvent.setup();
|
|
139
|
+
// The same concept under two roots resolves to one rendered row in the
|
|
140
|
+
// "Complete blood count" panel, but both leaves stay checkable in the
|
|
141
|
+
// filter tree. Checking either one must keep the collapsed row visible.
|
|
142
|
+
const roots = [plateletBranch('Hematology'), plateletBranch('Chemistry')];
|
|
143
|
+
|
|
144
|
+
mockUseGetManyObstreeData.mockReturnValue({
|
|
145
|
+
roots: roots as unknown as Array<ObsTreeNode>,
|
|
146
|
+
isLoading: false,
|
|
147
|
+
error: null,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
render(
|
|
151
|
+
<FilterProvider roots={roots as Roots} isLoading={false}>
|
|
152
|
+
<TreeView {...mockProps} />
|
|
153
|
+
</FilterProvider>,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
expect(screen.getAllByRole('table').length).toBeGreaterThan(0);
|
|
157
|
+
|
|
158
|
+
const plateletsCheckboxes = screen.getAllByRole('checkbox', { name: /platelets/i });
|
|
159
|
+
expect(plateletsCheckboxes).toHaveLength(2);
|
|
160
|
+
|
|
161
|
+
await user.click(plateletsCheckboxes[1]);
|
|
162
|
+
|
|
163
|
+
expect(plateletsCheckboxes[1]).toBeChecked();
|
|
164
|
+
expect(screen.getAllByRole('table').length).toBeGreaterThan(0);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
111
168
|
describe('Reset button - Tablet overlay', () => {
|
|
112
169
|
beforeEach(() => {
|
|
113
170
|
mockUseLayoutType.mockReturnValue('tablet');
|
package/src/types.ts
CHANGED
|
@@ -188,6 +188,8 @@ export type MappedObservation = {
|
|
|
188
188
|
hiAbsolute?: number;
|
|
189
189
|
hiCritical?: number;
|
|
190
190
|
flatName: string;
|
|
191
|
+
/** All flatNames that collapsed into this entry's normalized branch. */
|
|
192
|
+
flatNames?: Array<string>;
|
|
191
193
|
hasData: boolean;
|
|
192
194
|
range: string;
|
|
193
195
|
obsDatetime: string;
|