@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.
@@ -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: ReducerState['tests'] = activeTests?.length
111
- ? Object.fromEntries(Object.entries(state.tests).filter(([key]) => activeTests.includes(key)))
112
- : state.tests;
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
- Object.values(tests)
117
- .filter((test) => test?.obs && Array.isArray(test.obs))
118
- .map((test: ReducerState['tests']) => test?.obs?.map((entry) => entry.obsDatetime))
119
- .flat(),
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
- Object.values(tests).forEach((testData) => {
127
- if (testData?.obs && Array.isArray(testData.obs)) {
128
- const newEntries = allTimes.map((time) => testData.obs.find((entry) => entry.obsDatetime === time));
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
- for (const key in state.tests) {
145
- const test = state.tests[key] as TestResult;
146
- if (test.obs && Array.isArray(test.obs)) {
147
- test.obs.forEach((obs) => {
148
- // Dedupe by the observation's concept identity within its rendered panel.
149
- // The obstree backend can return the same concept under several branches
150
- // of the orderable-tests tree (e.g. one branch per order), each carrying
151
- // the same single obs. Keying on flatName (which differs per branch) left
152
- // those as separate rows, so a single result showed up N times. Scoping
153
- // to the panel group key keeps the same concept that legitimately appears
154
- // under different panels (e.g. mounted beneath two roots) as separate
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
- }, [state.tests]);
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
- const totalResultsCount: number = useMemo(() => {
206
- let count = 0;
207
- Object.values(state.tests).forEach((testData) => {
208
- if (testData?.obs && Array.isArray(testData.obs)) {
209
- count += testData.obs.length;
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
- let count = 0;
223
- activeTests.forEach((testKey) => {
224
- const test = state.tests[testKey];
225
- if (test?.obs && Array.isArray(test.obs)) {
226
- count += test.obs.length;
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
- // Handle duplicate keys by merging tests with the same flatName
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
- if (flatTests[key]) {
102
- // If we already have this test, merge the obs data
103
- if (test.obs && Array.isArray(test.obs)) {
104
- if (!flatTests[key].obs) {
105
- flatTests[key].obs = [];
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
- // Filter rowData to only include tests that belong to this panel
27
- const panelTestNames = panel.entries.map((entry) => entry.flatName);
28
- const subRows = rowData?.filter((row: { flatName: string }) => panelTestNames.includes(row.flatName));
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) => selectedCheckboxes.some((selectedKey) => entry.flatName === selectedKey))),
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;