Skip to content

Commit 7624fb1

Browse files
Port #1267 to dtq-dev-9-base: UFAL/stabilize form array reorder/delete behavior (ufal#120) (#1267) (#1502)
Two runtime bugs in repeatable submission fields. dedupeOperationEntries() collapsed duplicate patch operations only when they sat next to each other in the body: it walked backwards, remembered the last index per op+path, and spliced an entry out only when the remembered index was exactly one ahead of it. After a reorder or a delete the operations for one field are no longer adjacent, so stale operations survived into the PATCH and the value the user had just removed or moved came back on save. It is rewritten to keep the latest operation per op+path and drop every earlier one, which is what the two renamed specs now say. The two specs previously asserted the stale first operation was still in the body; both expected bodies lose that entry. The dedupe key is op+path, which ignores a move's `from`. That is safe here for the same reason it is safe on dtq-dev: this function only ever sees submission-form operations, whose paths are bare or numeric-indexed metadata paths, never the JSON Patch append form. The rewritten doc comment says so. Second, form-builder.service.ts computed `place = controlModelIndex || value.place` for object-valued controls. Index 0 is falsy, so the first entry of an array fell through to whatever stale `place` the value carried. It now tests for the index explicitly (hasArrayIndex), so position 0 keeps place 0. A new spec pins it. v9 notes: the source commit also rewrites dynamic-form-array.component.ts and its spec (66 of its 104 lines) to keep the FormArray in sync with the model on reorder. Those two files are deliberately NOT ported. Vanilla 9.3 solves the same desync its own way, with moveFormControlToPosition() (dynamic-form-array.component.ts:274), and the file is byte-identical with dspace-9.3 on this branch; transplanting the 7.x mechanism (moveFormArrayGroup/getControlOfGroup, neither of which exists on v9) would be a regression, and its spec tests exactly those absent methods. `git diff origin/dtq-dev-9-base HEAD --stat -- .../models/array-group/` is empty. The four ported files are hunk-identical with the source: for each of them `git show 164c424 -- <f> | grep -E '^[-+][^-+]'` and the same grep over this branch's diff produce byte-identical output. Card PB-02 (tranche T3). Source: 164c424 (dtq-dev PR #1267) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 82c6caf commit 7624fb1

4 files changed

Lines changed: 50 additions & 54 deletions

File tree

src/app/core/json-patch/json-patch-operations.reducer.spec.ts

Lines changed: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ describe('jsonPatchOperationsReducer test suite', () => {
341341
});
342342

343343
describe('dedupeOperationEntries', () => {
344-
it('should not remove duplicated keys if operations are not sequential', () => {
344+
it('should keep only the latest duplicated key even when operations are not sequential', () => {
345345
initState = {
346346
sections: {
347347
children: {
@@ -408,24 +408,6 @@ describe('jsonPatchOperationsReducer test suite', () => {
408408
const newState = jsonPatchOperationsReducer(initState, action);
409409

410410
const expectedBody: any = [
411-
{
412-
'operation': {
413-
'op': 'add',
414-
'path': '/sections/publicationStep/dc.date.issued',
415-
'value': [
416-
{
417-
'value': '2024-06',
418-
'language': null,
419-
'authority': null,
420-
'display': '2024-06',
421-
'confidence': -1,
422-
'place': 0,
423-
'otherInformation': null,
424-
},
425-
],
426-
},
427-
'timeCompleted': timestampBeforeStart,
428-
},
429411
{
430412
'operation': {
431413
'op': 'replace',
@@ -466,7 +448,7 @@ describe('jsonPatchOperationsReducer test suite', () => {
466448

467449
});
468450

469-
it('should remove duplicated keys if operations are sequential', () => {
451+
it('should keep only the latest duplicated key when operations are sequential', () => {
470452
initState = {
471453
sections: {
472454
children: {
@@ -551,24 +533,6 @@ describe('jsonPatchOperationsReducer test suite', () => {
551533
const newState = jsonPatchOperationsReducer(initState, action);
552534

553535
const expectedBody: any = [
554-
{
555-
'operation': {
556-
'op': 'add',
557-
'path': '/sections/publicationStep/dc.date.issued',
558-
'value': [
559-
{
560-
'value': '2024-06',
561-
'language': null,
562-
'authority': null,
563-
'display': '2024-06',
564-
'confidence': -1,
565-
'place': 0,
566-
'otherInformation': null,
567-
},
568-
],
569-
},
570-
'timeCompleted': timestampBeforeStart,
571-
},
572536
{
573537
'operation': {
574538
'op': 'replace',

src/app/core/json-patch/json-patch-operations.reducer.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -401,27 +401,28 @@ function addOperationToList(body: JsonPatchOperationObject[], actionType, target
401401

402402
/**
403403
* Dedupe operation entries by op and path. This prevents processing unnecessary patches in a single PATCH request.
404+
* For any given op+path combination, only the latest operation is retained (earlier duplicates are discarded).
405+
*
406+
* Note: this function is only called for submission-form patch operations, which always use numeric-indexed or
407+
* bare metadata paths (e.g. /sections/step/dc.title or /sections/step/dc.title/0). The JSON Patch append
408+
* notation (path ending in "/-") is intentionally never produced by this pipeline, so collapsing duplicates
409+
* by op+path is safe here.
404410
*
405411
* @param body JSON patch operation object entries
406412
* @returns deduped JSON patch operation object entries
407413
*/
408414
function dedupeOperationEntries(body: JsonPatchOperationObject[]): JsonPatchOperationObject[] {
409-
const ops = new Map<string, number>();
410-
for (let i = body.length - 1; i >= 0; i--) {
411-
const patch = body[i].operation;
412-
const key = `${patch.op}-${patch.path}`;
413-
if (!ops.has(key)) {
414-
ops.set(key, i);
415-
} else {
416-
const entry = ops.get(key);
417-
if (entry - 1 === i) {
418-
body.splice(i, 1);
419-
ops.set(key, i);
420-
}
421-
}
422-
}
415+
const lastIndexByOpPath = new Map<string, number>();
416+
417+
body.forEach((entry, index) => {
418+
const patch = entry.operation;
419+
lastIndexByOpPath.set(`${patch.op}-${patch.path}`, index);
420+
});
423421

424-
return body;
422+
return body.filter((entry, index) => {
423+
const patch = entry.operation;
424+
return lastIndexByOpPath.get(`${patch.op}-${patch.path}`) === index;
425+
});
425426
}
426427

427428
function makeOperationEntry(operation) {

src/app/shared/form/builder/form-builder.service.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,36 @@ describe('FormBuilderService test suite', () => {
543543
expect(service.getValueFromModel(formModel)).toEqual(value);
544544
});
545545

546+
it('should preserve place 0 for object-valued fields in arrays', () => {
547+
const arrayModel = new DynamicRowArrayModel({
548+
id: 'testObjectArray',
549+
initialCount: 1,
550+
notRepeatable: false,
551+
relationshipConfig: undefined,
552+
submissionId,
553+
isDraggable: true,
554+
groupFactory: () => [
555+
new DynamicInputModel({ id: 'dc_title' }),
556+
],
557+
required: false,
558+
metadataKey: 'dc.title',
559+
metadataFields: ['dc.title'],
560+
hasSelectableMetadata: true,
561+
showButtons: true,
562+
typeBindRelations: [],
563+
});
564+
565+
(arrayModel.groups[0].group[0] as any).name = 'dc.title';
566+
(arrayModel.groups[0].group[0] as any).value = {
567+
value: 'Title with stale place',
568+
place: 4,
569+
};
570+
571+
const value = service.getValueFromModel([arrayModel]);
572+
573+
expect(value['dc.title'][0].place).toBe(0);
574+
});
575+
546576
it('should clear all form\'s fields value', () => {
547577
const formModel = service.modelFromConfiguration(submissionId, testFormConfiguration, 'testScopeUUID');
548578
const value = {} as any;

src/app/shared/form/builder/form-builder.service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,8 @@ export class FormBuilderService extends DynamicFormService {
287287
return new FormFieldMetadataValueObject(dateToString(controlValue));
288288
} else if (isObject(controlValue)) {
289289
const authority = (controlValue as any).authority || (controlValue as any).id || null;
290-
const place = controlModelIndex || (controlValue as any).place;
290+
const hasArrayIndex = controlModelIndex !== null;
291+
const place = hasArrayIndex ? controlModelIndex : (controlValue as any).place;
291292
if (isNgbDateStruct(controlValue)) {
292293
return new FormFieldMetadataValueObject(controlValue, controlLanguage, authority, controlValue as any, place);
293294
} else {

0 commit comments

Comments
 (0)