Skip to content

Commit eed7d30

Browse files
KasinhouMatus Kasakmilanmajchrak
authored
UFAL/Resolve duplicate html IDs in submission form (#1234)
* Resolve duplicate HTML IDs in submission form. * refactor for unique ID registry * fix: remove unused baseId param from release in UniqueIdRegistry * fix: propagate unique ID from container to child form controls for accessibility * fix: recycle released suffixes in UniqueIdRegistry to preserve stable IDs across re-renders * refactor: deduplicate UniqueIdRegistry usage, store instanceKey, and add unit tests * Refactored the code to make this fix more simple * Updated components to make IT pass * refactor: replace UniqueIdRegistry with inline static counter for unique form IDs * Add tests to verify _idState cleanup and reuse of base id after ngOnDestroy --------- Co-authored-by: Matus Kasak <matus.kasak@dataquest.sk> Co-authored-by: milanmajchrak <milan.majchrak@dataquest.sk>
1 parent 6e743aa commit eed7d30

5 files changed

Lines changed: 149 additions & 3 deletions

File tree

src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
[formGroup]="group"
44
[ngClass]="[getClass('element', 'container'), getClass('grid', 'container')]">
55
<label *ngIf="!isCheckbox && hasLabel"
6-
[id]="'label_' + model.id"
6+
[id]="'label_' + id"
77
[for]="id"
88
[innerHTML]="(model.required && model.label) ? (model.label | translate) + ' *' : (model.label | translate)"
99
[ngClass]="[getClass('element', 'label'), getClass('grid', 'label')]"></label>

src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,4 +377,78 @@ describe('DsDynamicFormControlContainerComponent test suite', () => {
377377
expect(testFn(formModel[25])).toEqual(DsDynamicFormGroupComponent);
378378
});
379379

380+
describe('unique id generation', () => {
381+
afterEach(() => {
382+
DsDynamicFormControlContainerComponent.resetIdCounters();
383+
});
384+
385+
it('should return the base element ID for the first instance of a given id', () => {
386+
// testModel (formModel[8]) has id 'input' — first instance keeps original
387+
expect(component.id).toBe(component.model.id);
388+
});
389+
390+
it('should return a suffixed ID for the second instance of the same base id', () => {
391+
// Simulate two separate container instances with the same base id.
392+
// The first call to the getter registers 'input' → suffix 0 (original).
393+
expect(component.id).toBe('input');
394+
395+
// Create a second component-like access: directly exercise the getter
396+
// on a fresh component that shares the same model id.
397+
const secondComponent = Object.create(component);
398+
secondComponent._cachedId = undefined;
399+
secondComponent._baseId = undefined;
400+
// model with the same id but a new instance
401+
secondComponent.model = new DynamicInputModel({ id: 'input' });
402+
expect(secondComponent.id).toBe('input_1');
403+
});
404+
405+
it('should not interfere between different base ids', () => {
406+
expect(component.id).toBe('input'); // registers 'input'
407+
408+
const otherComponent = Object.create(component);
409+
otherComponent._cachedId = undefined;
410+
otherComponent._baseId = undefined;
411+
otherComponent.model = new DynamicInputModel({ id: 'email' });
412+
expect(otherComponent.id).toBe('email'); // first 'email' → original
413+
});
414+
415+
it('should return the same id on repeated access (idempotent)', () => {
416+
const first = component.id;
417+
const second = component.id;
418+
expect(first).toBe(second);
419+
});
420+
421+
it('should remove the id state entry so the next instance reuses the base id', () => {
422+
expect(component.id).toBe('input');
423+
424+
// Destroy the only active instance — state entry should be deleted
425+
component.ngOnDestroy();
426+
// A new component with the same base id should receive the original base id (no suffix)
427+
const newComponent = Object.create(component);
428+
newComponent._cachedId = undefined;
429+
newComponent._baseId = undefined;
430+
newComponent.model = new DynamicInputModel({ id: 'input' });
431+
expect(newComponent.id).toBe('input');
432+
});
433+
434+
it('should keep the id state entry when other instances with the same base id are still active', () => {
435+
// Register two instances with the same base id
436+
expect(component.id).toBe('input');
437+
438+
const secondComponent = Object.create(component);
439+
secondComponent._cachedId = undefined;
440+
secondComponent._baseId = undefined;
441+
secondComponent.model = new DynamicInputModel({ id: 'input' });
442+
expect(secondComponent.id).toBe('input_1');
443+
444+
component.ngOnDestroy();// destroy only the first instance
445+
// A third component with the same base id should still get a suffixed id
446+
const thirdComponent = Object.create(component);
447+
thirdComponent._cachedId = undefined;
448+
thirdComponent._baseId = undefined;
449+
thirdComponent.model = new DynamicInputModel({ id: 'input' });
450+
expect(thirdComponent.id).toBe('input_2');
451+
});
452+
});
453+
380454
});

src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,16 @@ export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type<
210210
changeDetection: ChangeDetectionStrategy.Default
211211
})
212212
export class DsDynamicFormControlContainerComponent extends DynamicFormControlContainerComponent implements OnInit, OnChanges, OnDestroy {
213+
214+
/**
215+
* Tracks per-baseId state for unique ID generation.
216+
* nextSuffix: the next numeric suffix to assign (0 means keep original ID).
217+
* activeCount: number of live component instances using this baseId
218+
* — when it drops to 0 the entry is removed so that a later page visit
219+
* starts fresh.
220+
*/
221+
private static _idState = new Map<string, { nextSuffix: number; activeCount: number }>();
222+
213223
@ContentChildren(DynamicTemplateDirective) contentTemplateList: QueryList<DynamicTemplateDirective>;
214224
// eslint-disable-next-line @angular-eslint/no-input-rename
215225
@Input('templates') inputTemplateList: QueryList<DynamicTemplateDirective>;
@@ -254,6 +264,35 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
254264
*/
255265
fetchThumbnail: boolean;
256266

267+
private _cachedId: string;
268+
private _baseId: string;
269+
270+
/**
271+
* Returns a unique element ID for this component instance.
272+
* The first instance of every base ID keeps the original value;
273+
* subsequent instances get a numeric suffix (_1, _2, …).
274+
*/
275+
get id(): string {
276+
if (!this._cachedId) {
277+
this._baseId = this.layoutService.getElementId(this.model);
278+
const state = DsDynamicFormControlContainerComponent._idState.get(this._baseId)
279+
|| { nextSuffix: 0, activeCount: 0 };
280+
this._cachedId = state.nextSuffix === 0 ? this._baseId : `${this._baseId}_${state.nextSuffix}`;
281+
state.nextSuffix++;
282+
state.activeCount++;
283+
DsDynamicFormControlContainerComponent._idState.set(this._baseId, state);
284+
}
285+
return this._cachedId;
286+
}
287+
288+
/**
289+
* Clears the global ID counter state. Used in tests to prevent
290+
* state leaking between test cases.
291+
*/
292+
static resetIdCounters(): void {
293+
DsDynamicFormControlContainerComponent._idState.clear();
294+
}
295+
257296
get componentType(): Type<DynamicFormControl> | null {
258297
return dsDynamicFormControlMapFn(this.model);
259298
}
@@ -403,6 +442,24 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
403442
(instance as any).formModel = this.formModel;
404443
(instance as any).formGroup = this.formGroup;
405444
}
445+
446+
// When this container's unique ID differs from the base model ID
447+
// (i.e., this is a duplicate instance), propagate the suffixed ID
448+
// to the child form control component so that the rendered element
449+
// id matches the container's label[for].
450+
//
451+
// Object.defineProperty is used because child components from the
452+
// third-party @ng-dynamic-forms library expose `id` as a getter
453+
// on the prototype; an instance-level property takes precedence.
454+
if (this.componentRef?.instance) {
455+
if (this.id !== this._baseId) {
456+
const uniqueId = this.id;
457+
Object.defineProperty(this.componentRef.instance, 'id', {
458+
get: () => uniqueId,
459+
configurable: true,
460+
});
461+
}
462+
}
406463
}
407464
}
408465

@@ -499,9 +556,19 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
499556
}
500557

501558
/**
502-
* Unsubscribe from all subscriptions
559+
* Unsubscribe from all subscriptions and clean up the ID counter
560+
* for this instance's base ID.
503561
*/
504562
ngOnDestroy(): void {
563+
if (this._baseId) {
564+
const state = DsDynamicFormControlContainerComponent._idState.get(this._baseId);
565+
if (state) {
566+
state.activeCount--;
567+
if (state.activeCount <= 0) {
568+
DsDynamicFormControlContainerComponent._idState.delete(this._baseId);
569+
}
570+
}
571+
}
505572
this.subs
506573
.filter((sub) => hasValue(sub))
507574
.forEach((sub) => sub.unsubscribe());

src/app/submission/sections/clarin-license-resource/section-license.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
*ngFor="let license of filteredLicenses4Selector"
6969
(click)="selectLicense(license.id)"
7070
[value]="license.id"
71-
id="license_option_{{ license.id }}">
71+
[id]="'license_option_' + license.id">
7272
<span [class]="'label label-default label-' + license.licenseLabel">{{license.licenseLabel}}</span>
7373
<b class="pl-1">{{license.name}}</b>
7474
</li>

src/test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {
88
platformBrowserDynamicTesting
99
} from '@angular/platform-browser-dynamic/testing';
1010
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
11+
import {
12+
DsDynamicFormControlContainerComponent
13+
} from './app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component';
1114

1215
// First, initialize the Angular testing environment.
1316
getTestBed().initTestEnvironment(
@@ -21,4 +24,6 @@ jasmine.getEnv().afterEach(() => {
2124
getTestBed().inject(MockStore, null)?.resetSelectors();
2225
// Close any leftover modals
2326
getTestBed().inject(NgbModal, null)?.dismissAll?.();
27+
// Reset unique-ID counters so state does not leak between test cases
28+
DsDynamicFormControlContainerComponent.resetIdCounters();
2429
});

0 commit comments

Comments
 (0)