Skip to content

Commit a2714b2

Browse files
committed
(fix) Scope concept-only fallback in findObsByFormField to respect obsGroup boundaries
1 parent 77d2353 commit a2714b2

File tree

2 files changed

+134
-1
lines changed

2 files changed

+134
-1
lines changed

src/adapters/obs-adapter.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1086,6 +1086,110 @@ describe('findObsByFormField', () => {
10861086
expect(matchedObs.length).toBe(1);
10871087
expect(matchedObs[0]).toBe(obsList[3]);
10881088
});
1089+
1090+
it('Should scope concept fallback to the parent obsGroup when field has a groupId', () => {
1091+
// Scenario: A form has two obsGroups — "Blood Tests" and "Urine Tests" — whose
1092+
// child fields share the same "Result Value" concept. When editing an encounter
1093+
// that only has Urine Test data, the Blood Test child field should NOT steal the
1094+
// Urine Test obs via concept fallback.
1095+
const resultValueConcept = 'shared-result-value-concept-uuid';
1096+
1097+
const bloodTestResultField: FormField = {
1098+
label: 'Blood Test Result',
1099+
type: 'obs',
1100+
questionOptions: { rendering: 'number', concept: resultValueConcept },
1101+
id: 'blood_test_result',
1102+
meta: { groupId: 'bloodTestGroup' },
1103+
};
1104+
1105+
const urineResultObs = {
1106+
uuid: 'urine-result-obs-uuid',
1107+
concept: { uuid: resultValueConcept },
1108+
formFieldNamespace: 'rfe-forms',
1109+
formFieldPath: 'rfe-forms-urine_test_result',
1110+
};
1111+
1112+
const urineGroupObs = {
1113+
uuid: 'urine-group-obs-uuid',
1114+
concept: { uuid: 'urine-group-concept' },
1115+
formFieldNamespace: 'rfe-forms',
1116+
formFieldPath: 'rfe-forms-urineTestGroup',
1117+
groupMembers: [urineResultObs],
1118+
};
1119+
1120+
const flatObs = [urineGroupObs, urineResultObs];
1121+
1122+
// Blood test field should NOT match the urine test obs
1123+
const matched = findObsByFormField(flatObs, [], bloodTestResultField);
1124+
expect(matched.length).toBe(0);
1125+
});
1126+
1127+
it('Should allow concept fallback within the correct parent obsGroup', () => {
1128+
const resultValueConcept = 'shared-result-value-concept-uuid';
1129+
1130+
const urineResultField: FormField = {
1131+
label: 'Urine Test Result',
1132+
type: 'obs',
1133+
questionOptions: { rendering: 'number', concept: resultValueConcept },
1134+
id: 'urine_test_result_v2', // different ID so path match fails
1135+
meta: { groupId: 'urineTestGroup' },
1136+
};
1137+
1138+
const urineResultObs = {
1139+
uuid: 'urine-result-obs-uuid',
1140+
concept: { uuid: resultValueConcept },
1141+
formFieldNamespace: 'rfe-forms',
1142+
formFieldPath: 'rfe-forms-urine_test_result', // doesn't match field ID
1143+
};
1144+
1145+
const urineGroupObs = {
1146+
uuid: 'urine-group-obs-uuid',
1147+
concept: { uuid: 'urine-group-concept' },
1148+
formFieldNamespace: 'rfe-forms',
1149+
formFieldPath: 'rfe-forms-urineTestGroup',
1150+
groupMembers: [urineResultObs],
1151+
};
1152+
1153+
const flatObs = [urineGroupObs, urineResultObs];
1154+
1155+
// Urine result field SHOULD match via concept fallback (correct group)
1156+
const matched = findObsByFormField(flatObs, [], urineResultField);
1157+
expect(matched.length).toBe(1);
1158+
expect(matched[0]).toBe(urineResultObs);
1159+
});
1160+
1161+
it('Should preserve backward compatibility when parent obsGroup has no formFieldPath', () => {
1162+
const resultValueConcept = 'shared-result-value-concept-uuid';
1163+
1164+
const childField: FormField = {
1165+
label: 'Test Result',
1166+
type: 'obs',
1167+
questionOptions: { rendering: 'number', concept: resultValueConcept },
1168+
id: 'test_result',
1169+
meta: { groupId: 'labTestGroup' },
1170+
};
1171+
1172+
const resultObs = {
1173+
uuid: 'result-obs-uuid',
1174+
concept: { uuid: resultValueConcept },
1175+
formFieldNamespace: 'rfe-forms',
1176+
formFieldPath: 'rfe-forms-old_field_id',
1177+
};
1178+
1179+
// Parent group saved from an older form version without formFieldPath
1180+
const groupObs = {
1181+
uuid: 'group-obs-uuid',
1182+
concept: { uuid: 'group-concept' },
1183+
groupMembers: [resultObs],
1184+
};
1185+
1186+
const flatObs = [groupObs, resultObs];
1187+
1188+
// No formFieldPath on parent → backward compat → allow fallback
1189+
const matched = findObsByFormField(flatObs, [], childField);
1190+
expect(matched.length).toBe(1);
1191+
expect(matched[0]).toBe(resultObs);
1192+
});
10891193
});
10901194

10911195
describe('ObsAdapter - handling nested obsGroups', () => {

src/adapters/obs-adapter.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,11 @@ function handleAttachments(field: FormField, attachments: Attachment[] = []) {
268268
* Notes:
269269
* If the query by field-path returns an empty list, the function falls back to querying
270270
* by concept and uses `claimedObsIds` to exclude already assigned observations.
271+
*
272+
* When the field belongs to an obsGroup (i.e. `field.meta.groupId` is set), the concept
273+
* fallback is scoped to observations that are members of the matching parent obsGroup.
274+
* This prevents child obs from one obsGroup from being incorrectly assigned to fields
275+
* in a different obsGroup that happens to share the same concept.
271276
*/
272277
export function findObsByFormField(
273278
obsList: Array<OpenmrsObs>,
@@ -287,7 +292,31 @@ export function findObsByFormField(
287292
// We shall fall back to mapping by the associated concept
288293
// That being said, we shall find all matching obs and pick the one that wasn't previously claimed.
289294
if (!obs?.length) {
290-
const obsByConcept = obsList.filter((obs) => obs.concept.uuid == field.questionOptions.concept);
295+
let obsByConcept = obsList.filter((obs) => obs.concept.uuid == field.questionOptions.concept);
296+
297+
// If this field belongs to an obsGroup, restrict the concept fallback to
298+
// respect obsGroup boundaries. For each candidate obs we check whether it
299+
// is a member of any obsGroup in the encounter. Three cases:
300+
// 1. The obs is standalone (not in any group) → allow (normal fallback).
301+
// 2. The obs is in a group whose formFieldPath matches the expected
302+
// parent → allow (correct group).
303+
// 3. The obs is in a group with a *different* formFieldPath → exclude
304+
// (prevents cross-group concept bleeding, e.g. PrEP → ARV).
305+
// 4. The obs is in a group that has no formFieldPath (old encounter) →
306+
// allow (backward compatibility).
307+
if (field.meta?.groupId) {
308+
const parentPath = `rfe-forms-${field.meta.groupId}`;
309+
obsByConcept = obsByConcept.filter((candidate) => {
310+
// Find the obsGroup that owns this candidate (if any)
311+
const ownerGroup = obsList.find(
312+
(o) => o.groupMembers?.some((m) => m.uuid === candidate.uuid),
313+
);
314+
if (!ownerGroup) return true; // case 1: standalone
315+
if (!ownerGroup.formFieldPath) return true; // case 4: old encounter
316+
return ownerGroup.formFieldPath == parentPath; // case 2 or 3
317+
});
318+
}
319+
291320
return claimedObsIds?.length ? obsByConcept.filter((obs) => !claimedObsIds.includes(obs.uuid)) : obsByConcept;
292321
}
293322

0 commit comments

Comments
 (0)