diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html
index 606db29db37..9e73b55f751 100644
--- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html
+++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html
@@ -3,7 +3,7 @@
[formGroup]="group"
[ngClass]="[getClass('element', 'container'), getClass('grid', 'container')]">
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts
index 355e10b9a09..bb6f33c290f 100644
--- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts
+++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts
@@ -377,4 +377,78 @@ describe('DsDynamicFormControlContainerComponent test suite', () => {
expect(testFn(formModel[25])).toEqual(DsDynamicFormGroupComponent);
});
+ describe('unique id generation', () => {
+ afterEach(() => {
+ DsDynamicFormControlContainerComponent.resetIdCounters();
+ });
+
+ it('should return the base element ID for the first instance of a given id', () => {
+ // testModel (formModel[8]) has id 'input' — first instance keeps original
+ expect(component.id).toBe(component.model.id);
+ });
+
+ it('should return a suffixed ID for the second instance of the same base id', () => {
+ // Simulate two separate container instances with the same base id.
+ // The first call to the getter registers 'input' → suffix 0 (original).
+ expect(component.id).toBe('input');
+
+ // Create a second component-like access: directly exercise the getter
+ // on a fresh component that shares the same model id.
+ const secondComponent = Object.create(component);
+ secondComponent._cachedId = undefined;
+ secondComponent._baseId = undefined;
+ // model with the same id but a new instance
+ secondComponent.model = new DynamicInputModel({ id: 'input' });
+ expect(secondComponent.id).toBe('input_1');
+ });
+
+ it('should not interfere between different base ids', () => {
+ expect(component.id).toBe('input'); // registers 'input'
+
+ const otherComponent = Object.create(component);
+ otherComponent._cachedId = undefined;
+ otherComponent._baseId = undefined;
+ otherComponent.model = new DynamicInputModel({ id: 'email' });
+ expect(otherComponent.id).toBe('email'); // first 'email' → original
+ });
+
+ it('should return the same id on repeated access (idempotent)', () => {
+ const first = component.id;
+ const second = component.id;
+ expect(first).toBe(second);
+ });
+
+ it('should remove the id state entry so the next instance reuses the base id', () => {
+ expect(component.id).toBe('input');
+
+ // Destroy the only active instance — state entry should be deleted
+ component.ngOnDestroy();
+ // A new component with the same base id should receive the original base id (no suffix)
+ const newComponent = Object.create(component);
+ newComponent._cachedId = undefined;
+ newComponent._baseId = undefined;
+ newComponent.model = new DynamicInputModel({ id: 'input' });
+ expect(newComponent.id).toBe('input');
+ });
+
+ it('should keep the id state entry when other instances with the same base id are still active', () => {
+ // Register two instances with the same base id
+ expect(component.id).toBe('input');
+
+ const secondComponent = Object.create(component);
+ secondComponent._cachedId = undefined;
+ secondComponent._baseId = undefined;
+ secondComponent.model = new DynamicInputModel({ id: 'input' });
+ expect(secondComponent.id).toBe('input_1');
+
+ component.ngOnDestroy();// destroy only the first instance
+ // A third component with the same base id should still get a suffixed id
+ const thirdComponent = Object.create(component);
+ thirdComponent._cachedId = undefined;
+ thirdComponent._baseId = undefined;
+ thirdComponent.model = new DynamicInputModel({ id: 'input' });
+ expect(thirdComponent.id).toBe('input_2');
+ });
+ });
+
});
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts
index f2ea9c3baf2..15ca2912e0b 100644
--- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts
+++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts
@@ -210,6 +210,16 @@ export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type<
changeDetection: ChangeDetectionStrategy.Default
})
export class DsDynamicFormControlContainerComponent extends DynamicFormControlContainerComponent implements OnInit, OnChanges, OnDestroy {
+
+ /**
+ * Tracks per-baseId state for unique ID generation.
+ * nextSuffix: the next numeric suffix to assign (0 means keep original ID).
+ * activeCount: number of live component instances using this baseId
+ * — when it drops to 0 the entry is removed so that a later page visit
+ * starts fresh.
+ */
+ private static _idState = new Map();
+
@ContentChildren(DynamicTemplateDirective) contentTemplateList: QueryList;
// eslint-disable-next-line @angular-eslint/no-input-rename
@Input('templates') inputTemplateList: QueryList;
@@ -254,6 +264,35 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
*/
fetchThumbnail: boolean;
+ private _cachedId: string;
+ private _baseId: string;
+
+ /**
+ * Returns a unique element ID for this component instance.
+ * The first instance of every base ID keeps the original value;
+ * subsequent instances get a numeric suffix (_1, _2, …).
+ */
+ get id(): string {
+ if (!this._cachedId) {
+ this._baseId = this.layoutService.getElementId(this.model);
+ const state = DsDynamicFormControlContainerComponent._idState.get(this._baseId)
+ || { nextSuffix: 0, activeCount: 0 };
+ this._cachedId = state.nextSuffix === 0 ? this._baseId : `${this._baseId}_${state.nextSuffix}`;
+ state.nextSuffix++;
+ state.activeCount++;
+ DsDynamicFormControlContainerComponent._idState.set(this._baseId, state);
+ }
+ return this._cachedId;
+ }
+
+ /**
+ * Clears the global ID counter state. Used in tests to prevent
+ * state leaking between test cases.
+ */
+ static resetIdCounters(): void {
+ DsDynamicFormControlContainerComponent._idState.clear();
+ }
+
get componentType(): Type | null {
return dsDynamicFormControlMapFn(this.model);
}
@@ -403,6 +442,24 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
(instance as any).formModel = this.formModel;
(instance as any).formGroup = this.formGroup;
}
+
+ // When this container's unique ID differs from the base model ID
+ // (i.e., this is a duplicate instance), propagate the suffixed ID
+ // to the child form control component so that the rendered element
+ // id matches the container's label[for].
+ //
+ // Object.defineProperty is used because child components from the
+ // third-party @ng-dynamic-forms library expose `id` as a getter
+ // on the prototype; an instance-level property takes precedence.
+ if (this.componentRef?.instance) {
+ if (this.id !== this._baseId) {
+ const uniqueId = this.id;
+ Object.defineProperty(this.componentRef.instance, 'id', {
+ get: () => uniqueId,
+ configurable: true,
+ });
+ }
+ }
}
}
@@ -499,9 +556,19 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
}
/**
- * Unsubscribe from all subscriptions
+ * Unsubscribe from all subscriptions and clean up the ID counter
+ * for this instance's base ID.
*/
ngOnDestroy(): void {
+ if (this._baseId) {
+ const state = DsDynamicFormControlContainerComponent._idState.get(this._baseId);
+ if (state) {
+ state.activeCount--;
+ if (state.activeCount <= 0) {
+ DsDynamicFormControlContainerComponent._idState.delete(this._baseId);
+ }
+ }
+ }
this.subs
.filter((sub) => hasValue(sub))
.forEach((sub) => sub.unsubscribe());
diff --git a/src/app/submission/sections/clarin-license-resource/section-license.component.html b/src/app/submission/sections/clarin-license-resource/section-license.component.html
index ea14ada884a..31e671a0187 100644
--- a/src/app/submission/sections/clarin-license-resource/section-license.component.html
+++ b/src/app/submission/sections/clarin-license-resource/section-license.component.html
@@ -68,7 +68,7 @@
*ngFor="let license of filteredLicenses4Selector"
(click)="selectLicense(license.id)"
[value]="license.id"
- id="license_option_{{ license.id }}">
+ [id]="'license_option_' + license.id">
{{license.licenseLabel}}{{license.name}}
diff --git a/src/test.ts b/src/test.ts
index 2f07cf0d1da..c401ca80eb7 100644
--- a/src/test.ts
+++ b/src/test.ts
@@ -8,6 +8,9 @@ import {
platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
+import {
+ DsDynamicFormControlContainerComponent
+} from './app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component';
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
@@ -21,4 +24,6 @@ jasmine.getEnv().afterEach(() => {
getTestBed().inject(MockStore, null)?.resetSelectors();
// Close any leftover modals
getTestBed().inject(NgbModal, null)?.dismissAll?.();
+ // Reset unique-ID counters so state does not leak between test cases
+ DsDynamicFormControlContainerComponent.resetIdCounters();
});