Skip to content

Commit 5e95183

Browse files
clinnygeeclaude
andcommitted
fix: repopulate corrupts repeat groups when rows are deleted on the server (issue #1940)
The Value Restoration step in getFilteredItemsToRepopulate spliced accepted deletions out of serverQRItems inside the same forward loop that fills unselected slots from current values. Splicing shifted unvisited deletion markers into already-visited indices (so they leaked into the QuestionnaireResponse) and re-opened their vacated tail slots to be re-filled from current values, resurrecting the deleted rows as duplicates. Split the loop into two passes: fill unselected slots first, then filter out all items marked with the mark-as-deleted extension. This also strips the internal marker extension from the final response and drops sparse-array holes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d2d64c3 commit 5e95183

2 files changed

Lines changed: 91 additions & 11 deletions

File tree

apps/smart-forms-app/src/features/repopulate/test/itemsToRepopulateSelector.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,86 @@ describe('getFilteredItemsToRepopulate', () => {
238238
// currentQRItem should stay undefined / as per original
239239
expect(filtered['63fe14f3-2374-4382-bce7-180e2747c97f'].currentQRItem).toBeUndefined();
240240
});
241+
242+
// Regression tests for https://github.com/aehrc/smart-forms/issues/1940
243+
// Rows deleted on the server between repopulations: the dialog correctly shows the
244+
// deletions, but applying them corrupted the result (deleted rows resurrected/duplicated
245+
// and the internal mark-as-deleted extension leaked into the QuestionnaireResponse).
246+
describe('server-side deletions in repeat groups', () => {
247+
const markAsDeletedUrl =
248+
'https://smartforms.csiro.au/custom-functionality/repopulation/mark-as-deleted';
249+
250+
function buildRepeatGroupRow(value: string) {
251+
return {
252+
linkId: 'problem',
253+
item: [{ linkId: 'problem-name', answer: [{ valueString: value }] }]
254+
};
255+
}
256+
257+
const rowA = buildRepeatGroupRow('A');
258+
const rowB = buildRepeatGroupRow('B');
259+
const rowC = buildRepeatGroupRow('C');
260+
261+
function buildDeletionScenario(): {
262+
tuplesMap: Map<string, [string, ItemToRepopulate][]>;
263+
originalItems: Record<string, ItemToRepopulate>;
264+
} {
265+
// Form has rows [A, B, C]; rows A and B were deleted on the server, so it returns [C].
266+
// Detection (retrieveRepeatGroupCurrentQRItems) aligns matched pairs first:
267+
// currentQRItems = [C, A, B], serverQRItems = [C]
268+
// → dialog child 0 hidden (C unchanged), child 1 "A removed", child 2 "B removed"
269+
const itemToRepopulate: ItemToRepopulate = {
270+
qItem: { linkId: 'problem', text: 'Recorded problems', type: 'group', repeats: true },
271+
sectionItemText: 'Clinical details',
272+
parentItemText: 'Clinical details',
273+
isInGrid: false,
274+
currentQRItems: [rowC, rowA, rowB],
275+
serverQRItems: [rowC]
276+
};
277+
278+
return {
279+
tuplesMap: new Map([['Clinical details', [['problem', itemToRepopulate]]]]),
280+
originalItems: { problem: itemToRepopulate }
281+
};
282+
}
283+
284+
it('removes all accepted deletions instead of resurrecting/duplicating rows', () => {
285+
const { tuplesMap, originalItems } = buildDeletionScenario();
286+
287+
// Accept both deletions, as shown in the dialog
288+
const selectedKeys = new Set(['heading-0-parent-0-child-1', 'heading-0-parent-0-child-2']);
289+
290+
const filtered = getFilteredItemsToRepopulate(tuplesMap, selectedKeys, originalItems);
291+
292+
// The dialog promised only row C remains
293+
expect(filtered['problem'].serverQRItems).toEqual([rowC]);
294+
});
295+
296+
it('does not leak the mark-as-deleted extension into the result', () => {
297+
const { tuplesMap, originalItems } = buildDeletionScenario();
298+
299+
const selectedKeys = new Set(['heading-0-parent-0-child-1', 'heading-0-parent-0-child-2']);
300+
301+
const filtered = getFilteredItemsToRepopulate(tuplesMap, selectedKeys, originalItems);
302+
303+
const leakedMarkers = (filtered['problem'].serverQRItems ?? []).filter((serverQRItem) =>
304+
serverQRItem.extension?.some((ext) => ext.url === markAsDeletedUrl)
305+
);
306+
expect(leakedMarkers).toEqual([]);
307+
});
308+
309+
it('keeps unaccepted deletions in the form', () => {
310+
const { tuplesMap, originalItems } = buildDeletionScenario();
311+
312+
// Accept only the deletion of row A; leave row B's deletion unselected
313+
const selectedKeys = new Set(['heading-0-parent-0-child-1']);
314+
315+
const filtered = getFilteredItemsToRepopulate(tuplesMap, selectedKeys, originalItems);
316+
317+
// Row A removed, row B preserved from current values
318+
expect(filtered['problem'].serverQRItems).toEqual([rowC, rowB]);
319+
});
320+
});
241321
});
242322

243323
describe('getChipColorByValueChangeMode', () => {

apps/smart-forms-app/src/features/repopulate/utils/itemsToRepopulateSelector.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -251,22 +251,22 @@ export function getFilteredItemsToRepopulate(
251251
filteredItem.serverQRItems = [];
252252
}
253253

254+
// Fill unselected slots with current values first, then drop accepted deletions in a
255+
// separate pass. Doing both in a single loop corrupts the result: splicing shifts later
256+
// items into already-visited indices (so a deletion marker can survive) and re-opens
257+
// their vacated tail slots to be re-filled from current values (resurrecting deleted items).
254258
for (let i = 0; i < maxNumberOfItems; i++) {
255259
if (filteredItem.serverQRItems[i] === undefined && originalItem.currentQRItems[i]) {
256260
filteredItem.serverQRItems[i] = originalItem.currentQRItems[i];
257261
}
258-
259-
// If the original item is marked as deleted (has the https://smartforms.csiro.au/custom-functionality/repopulation/mark-as-deleted extension), remove the deleted item
260-
if (filteredItem.serverQRItems[i]) {
261-
const hasDeletedExtension = itemHasMarkAsDeletedExtension(
262-
filteredItem.serverQRItems[i]
263-
);
264-
265-
if (hasDeletedExtension) {
266-
filteredItem.serverQRItems.splice(i, 1);
267-
}
268-
}
269262
}
263+
264+
// Remove items accepted as deletions (marked with the https://smartforms.csiro.au/custom-functionality/repopulation/mark-as-deleted extension),
265+
// so the marker never leaks into the final QuestionnaireResponse. Also drops any holes left in the sparse array.
266+
filteredItem.serverQRItems = filteredItem.serverQRItems.filter(
267+
(serverQRItem) =>
268+
serverQRItem !== undefined && !itemHasMarkAsDeletedExtension(serverQRItem)
269+
);
270270
}
271271
}
272272
}

0 commit comments

Comments
 (0)