Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ import { DYNAMIC_FORM_CONTROL_TYPE_AUTOCOMPLETE } from './models/autocomplete/ds
import { DsDynamicSponsorAutocompleteComponent } from './models/sponsor-autocomplete/ds-dynamic-sponsor-autocomplete.component';
import { SPONSOR_METADATA_NAME } from './models/ds-dynamic-complex.model';
import { DsDynamicSponsorScrollableDropdownComponent } from './models/sponsor-scrollable-dropdown/dynamic-sponsor-scrollable-dropdown.component';
import { UniqueIdRegistry } from './unique-id-registry';

export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type<DynamicFormControl> | null {
switch (model.type) {
Expand Down Expand Up @@ -210,6 +211,19 @@ export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type<
changeDetection: ChangeDetectionStrategy.Default
})
export class DsDynamicFormControlContainerComponent extends DynamicFormControlContainerComponent implements OnInit, OnChanges, OnDestroy {

/**
* The unique element ID assigned to this component instance.
* For the first occurrence of a base ID, this equals the base ID (preserving backward compatibility).
* For subsequent occurrences, a numeric suffix is appended.
*/
private _uniqueId: string;

/**
* The base element ID (from getElementId) before deduplication.
*/
private _baseId: string;

@ContentChildren(DynamicTemplateDirective) contentTemplateList: QueryList<DynamicTemplateDirective>;
// eslint-disable-next-line @angular-eslint/no-input-rename
@Input('templates') inputTemplateList: QueryList<DynamicTemplateDirective>;
Expand Down Expand Up @@ -254,6 +268,20 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
*/
fetchThumbnail: boolean;

/**
* Returns a unique element ID for this component instance.
* The first occurrence of a base ID keeps the original value.
* Subsequent occurrences receive a numeric suffix.
*/
get id(): string {
if (!this._uniqueId) {
this._baseId = this.layoutService.getElementId(this.model);
const instanceKey = `${this._baseId}_${this.model?.parent?.id || 'root'}`;
this._uniqueId = UniqueIdRegistry.register(this._baseId, instanceKey);
}
return this._uniqueId;
}

get componentType(): Type<DynamicFormControl> | null {
return dsDynamicFormControlMapFn(this.model);
}
Expand Down Expand Up @@ -499,9 +527,13 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
}

/**
* Unsubscribe from all subscriptions
* Unsubscribe from all subscriptions and release the unique ID from the registry.
*/
ngOnDestroy(): void {
if (this._uniqueId && this._baseId) {
const instanceKey = `${this._baseId}_${this.model?.parent?.id || 'root'}`;
UniqueIdRegistry.release(instanceKey);
}
this.subs
.filter((sub) => hasValue(sub))
.forEach((sub) => sub.unsubscribe());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewChild,
Expand All @@ -29,6 +30,7 @@ import { FormFieldMetadataValueObject } from '../../../models/form-field-metadat
import { FindAllData } from '../../../../../../core/data/base/find-all-data';
import { CacheableObject } from '../../../../../../core/cache/cacheable-object.model';
import { RemoteData } from '../../../../../../core/data/remote-data';
import { UniqueIdRegistry } from '../../unique-id-registry';

/**
* Component representing a dropdown input field
Expand All @@ -38,7 +40,7 @@ import { RemoteData } from '../../../../../../core/data/remote-data';
styleUrls: ['./dynamic-scrollable-dropdown.component.scss'],
templateUrl: './dynamic-scrollable-dropdown.component.html'
})
export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyComponent implements OnInit {
export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyComponent implements OnInit, OnDestroy {
@ViewChild('dropdownMenu', { read: ElementRef }) dropdownMenu: ElementRef;

@Input() bindId = true;
Expand All @@ -57,6 +59,29 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom
public selectedIndex = 0;
public acceptableKeys = ['Space', 'NumpadMultiply', 'NumpadAdd', 'NumpadSubtract', 'NumpadDecimal', 'Semicolon', 'Equal', 'Comma', 'Minus', 'Period', 'Quote', 'Backquote'];

/**
* The unique element ID assigned to this component instance.
*/
private _uniqueId: string;

/**
* The base element ID before deduplication.
*/
private _baseId: string;

/**
* Returns a unique element ID for this scrollable dropdown instance.
* Prevents duplicate HTML IDs when the same model appears in multiple form groups.
*/
get id(): string {
if (!this._uniqueId) {
this._baseId = this.layoutService.getElementId(this.model);
const instanceKey = `${this._baseId}_${this.model?.parent?.id || 'root'}`;
this._uniqueId = UniqueIdRegistry.register(this._baseId, instanceKey);
}
return this._uniqueId;
}

/**
* If true the component can rely on the findAll method for data loading.
* This is a behaviour activated by dependency injection through the dropdown config.
Expand Down Expand Up @@ -293,4 +318,14 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom
this.currentValue = result;
}

/**
* Release the unique ID from the registry when the component is destroyed.
*/
ngOnDestroy(): void {
if (this._uniqueId && this._baseId) {
const instanceKey = `${this._baseId}_${this.model?.parent?.id || 'root'}`;
UniqueIdRegistry.release(instanceKey);
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Static registry for generating unique HTML element IDs across dynamic form components.
*
* When the same form model ID appears multiple times in the DOM (e.g., scrollable dropdowns
* for mediaType/detailedType in different type-bound form groups), this registry ensures
* each rendered instance receives a unique HTML ID.
*
* - First occurrence: keeps the original base ID (backward compatible).
* - Subsequent occurrences: appends a numeric suffix (`_1`, `_2`, etc.).
*
* Components must call `register()` during initialization and `release()` during destruction.
*/
export class UniqueIdRegistry {

/**
* Monotonic counter per base ID. Always increments, never decrements,
* so released suffixes are never reissued to a different instance.
* Key = base element ID, Value = next suffix to assign.
*/
private static nextSuffix: Map<string, number> = new Map<string, number>();

/**
* Tracks the assigned suffix for each component instance.
* Key = a unique instance token (component + model-based), Value = the suffix index assigned.
*/
private static instanceSuffixes: Map<string, number> = new Map<string, number>();

/**
* Register a base ID and return a unique ID for this instance.
* The first occurrence returns the base ID unchanged.
* Subsequent occurrences return `baseId_N` where N is the occurrence index (1, 2, ...).
*
* @param baseId The base element ID (from getElementId).
* @param instanceKey A unique key identifying this specific component instance.
* @returns The unique element ID to use in the DOM.
*/
static register(baseId: string, instanceKey: string): string {
// If this instance was already registered, return its existing ID
if (this.instanceSuffixes.has(instanceKey)) {
const suffix = this.instanceSuffixes.get(instanceKey);
return suffix === 0 ? baseId : `${baseId}_${suffix}`;
}

const suffix = this.nextSuffix.get(baseId) || 0;
this.nextSuffix.set(baseId, suffix + 1);
this.instanceSuffixes.set(instanceKey, suffix);
return suffix === 0 ? baseId : `${baseId}_${suffix}`;
}

/**
* Release the unique ID when a component is destroyed.
*
* @param instanceKey The unique key used during registration.
*/
static release(instanceKey: string): void {
this.instanceSuffixes.delete(instanceKey);
}

/**
* Clear the entire registry. Used in tests to reset state between specs.
*/
static clear(): void {
this.nextSuffix.clear();
this.instanceSuffixes.clear();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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">
<span [class]="'label label-default label-' + license.licenseLabel">{{license.licenseLabel}}</span>
<b class="pl-1">{{license.name}}</b>
</li>
Expand Down
3 changes: 3 additions & 0 deletions src/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { UniqueIdRegistry } from './app/shared/form/builder/ds-dynamic-form-ui/unique-id-registry';

// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
Expand All @@ -21,4 +22,6 @@ jasmine.getEnv().afterEach(() => {
getTestBed().inject(MockStore, null)?.resetSelectors();
// Close any leftover modals
getTestBed().inject(NgbModal, null)?.dismissAll?.();
// Reset unique ID registry to prevent static state leaking between tests
UniqueIdRegistry.clear();
});
Loading