1. This site uses cookies. By continuing to use this site, you are agreeing to our use of cookies. Learn More.
  2. Anuncie Aqui
    Anuncie aqui você Também: fdantas@4each.com.br

Dynamic angular form generation, timing issue

Discussão em 'Angular' iniciado por andrelange91, Maio 30, 2025.

  1. andrelange91

    andrelange91 Guest

    I am using angular 18. I am trying to build a component that can render a form, based on a usertype and add any data I set, if any. But i am having some serious issues with what appears to be timing issues.

    Basically I am trying to make a dynamic form, where I can show fields, based on the selected user type. Then it should look in a configuration, where all the settings are, and if any fields should be dependent on another and its value before being shown.

    But When I try to forexample make a toggle switch that has a username and password that is dependent on it, it just breaks!

    FYI be warned, wall of code inc. This is my config,

    export const USER_TYPE_FIELDS_EDIT: UserTypeFields[] = [
    {
    type: 'dealer',
    fields: {
    AXDealerId: {
    type: 'text',
    defaultValue: '',
    validators: [Validators.required],
    allowedForProviders: [123, 345],
    isDisabled: false,
    label: 'Internt Forhandler nummer'
    },
    showServiceCalculator: {
    type: 'toggle',
    defaultValue: false,
    validators: [Validators.required],
    isDisabled: false,
    label: 'Vis service'
    },
    serviceCalculatorUsername: {
    type: 'text',
    defaultValue: '',
    validators: [],
    isDisabled: false,
    label: 'Brugernavn til serviceberegner',
    dependsOn: { field: 'showServiceCalculator', value: true }
    },
    serviceCalculatorPassword: {
    type: 'text',
    defaultValue: '',
    validators: [],
    isDisabled: false,
    label: 'Adgangskode til serviceberegner',
    dependsOn: { field: 'showServiceCalculator', value: true }
    },
    showLeasingGuide: {
    type: 'toggle',
    defaultValue: false,
    validators: [Validators.required],
    isDisabled: false,
    label: 'Vis leasingguide'
    },
    connectedProducts: {
    type: 'select',
    defaultValue: [],
    validators: [Validators.required],
    isDisabled: false,
    label: 'Tilknyt produkter',
    placeholder: 'Vælg et eller flere produkter',
    isMultiple: true,
    }
    },
    availableForTypes: ['provideradmin', 'provideruser'],
    },
    {
    type: 'validator',
    fields: {},
    availableForTypes: ['provideradmin', 'provideruser'],
    },
    ];


    With this it generates among others, the toggle ShowServiceCalculator Then there are the two fields username / password. Those are dependent on the toggle above. And if the toggle is true, it should show them, if not then remove them.

    My issue here is that when ever i try to hide or show them, it just breaks, and can't find the formcontrols (ERROR Error: There is no FormControl instance attached to form control element with name: 'showServiceCalculator')

    This is my dynamic form component to build it:

    import {
    ChangeDetectorRef,
    Component,
    EventEmitter,
    Input,
    OnChanges,
    OnInit,
    Output,
    SimpleChanges
    } from '@angular/core';
    import {FormControl, ReactiveFormsModule, Validators} from '@angular/forms';
    import {SelectOption} from '../../../models/select-option.model'; // adjust import
    import {UserTypeFields} from "../../../models/user-type-fields.model";
    import {DealerService} from "../../../services/dealer.service";
    import {ProviderService} from "../../../services/provider.service";
    import {ProductService} from "../../../services/product.service";
    import {ToggleSwitchComponent} from "../../form-elements/toggle-switch/toggle-switch.component";
    import {TextInputComponent} from "../../form-elements/text-input/text-input.component";
    import {SelectSearchComponent} from "../../form-elements/select-search/select-search.component";
    import {LabelComponent} from "../../form-elements/label/label.component";
    import {EmailInputComponent} from "../../form-elements/email-input/email-input.component";
    import {ExtendedFormGroup} from "../../../../core/extensions/extended-form-group.utility";
    import {UserService} from "../../../../core/services/user.service";
    import {Subscription} from "rxjs";


    @Component({
    selector: 'app-user-dynamic-form',
    standalone: true,
    templateUrl: './user-dynamic-form.component.html',
    imports: [
    ToggleSwitchComponent,
    TextInputComponent,
    SelectSearchComponent,
    LabelComponent,
    EmailInputComponent,
    ReactiveFormsModule,
    ]
    })
    export class UserDynamicFormComponent implements OnInit, OnChanges {
    @Input() mode: 'create' | 'edit' = 'create';
    @Input() config!: UserTypeFields[];
    @Input() availableRoles: SelectOption[] = [];
    @Input() user: any = null; // for edit mode
    @Input() currentUserType: string = '';
    @Output() formSubmit = new EventEmitter<any>();

    dynamicFieldsConfig: any[] = [];
    role: string = '';
    public form: ExtendedFormGroup = new ExtendedFormGroup({});
    private dependencyListeners: Set<string> = new Set();
    private dependencySubscriptions: { [key: string]: Subscription } = {};

    constructor(
    private dealerService: DealerService,
    private providerService: ProviderService,
    private productService: ProductService,
    private userService: UserService,
    private cdRef: ChangeDetectorRef
    ) {
    }

    ngOnInit() {
    if (!this.config || this.config.length === 0) {
    throw new Error('User fields configuration is required!');
    }
    if (this.mode === 'edit' && this.user) {
    this.role = this.user.role.toLowerCase().replace(/\s+/g, '');
    // Give the role control, the label value of availableRoles as a translated value.
    let translatedRole = this.availableRoles.find(role => role.value === this.role)?.label || this.role;

    this.form.addControl('role', new FormControl({
    value: translatedRole,
    disabled: true
    }, Validators.required));
    this.form.addControl('email', new FormControl({
    value: this.user?.email || '',
    disabled: true
    }, [Validators.required, Validators.email]));

    // this.generateFormFields(this.role, this.user);
    } else {
    this.form.addControl('role', new FormControl('', Validators.required));
    this.form.get('role')?.valueChanges.subscribe(role => {
    this.role = role;
    this.generateFormFields(role);
    });
    this.form.addControl('email', new FormControl({
    value: this.user?.email || '',
    disabled: false
    }, [Validators.required, Validators.email]));
    }
    }

    ngOnChanges(changes: SimpleChanges) {
    if (changes['user'] && this.mode === 'edit' && this.user) {
    this.role = this.user.role.toLowerCase().replace(/\s+/g, '');
    this.generateFormFields(this.role, this.user);
    this.form.get('email')?.setValue(this.user.email);
    }
    }

    getCurrentProviderId(): number | undefined {
    return this.userService?.getProvider()?.id;
    }


    generateFormFields(role: string, initialData: any = {}) {
    // 1. Unsubscribe from previous dependency subscriptions
    Object.values(this.dependencySubscriptions).forEach(sub => sub.unsubscribe());
    this.dependencySubscriptions = {};

    const previousValues = { ...this.form.getRawValue() };

    // 2. Remove all controls except 'role' and 'email' (batch)
    const keep = new Set(['role', 'email']);
    Object.keys(this.form.controls).forEach(key => {
    if (!keep.has(key)) {
    this.form.removeControl(key);
    }
    });

    const currentUserType = (this.currentUserType || '').toLowerCase().replace(/\s+/g, '');
    const currentProviderId = this.getCurrentProviderId();

    // 3. Get config for this role
    const userTypeConfig = this.config.find(type => type.type === role);
    if (!userTypeConfig) {
    this.dynamicFieldsConfig = [];
    this.cdRef.detectChanges();
    return;
    }

    const fieldEntries = Object.entries(userTypeConfig.fields);

    // 4. Build orderedFields and dependents arrays
    const orderedFields: any[] = [];
    const dependents: Array<[string, any]> = [];

    fieldEntries.forEach(([fieldName, fieldConfig]) => {
    if (
    this.isAllowedForProvider(fieldConfig, currentProviderId) &&
    this.isAvailableForType(fieldConfig, currentUserType)
    ) {
    if (!fieldConfig.dependsOn) {
    const freshConfig = { ...fieldConfig, name: fieldName, options: fieldConfig.options ? [...fieldConfig.options] : [] };
    orderedFields.push(freshConfig);
    } else {
    dependents.push([fieldName, fieldConfig]);
    }
    }
    });

    // 5. Add all non-dependent controls (batch)
    orderedFields.forEach(fieldConfig => {
    const fieldName = fieldConfig.name;
    if (!this.form.contains(fieldName)) {
    this.form.addControl(
    fieldName,
    new FormControl(
    {
    value: initialData[fieldName] !== undefined
    ? initialData[fieldName]
    : (previousValues[fieldName] !== undefined
    ? previousValues[fieldName]
    : (fieldConfig.isMultiple ? [] : fieldConfig.defaultValue)),
    disabled: fieldConfig.isDisabled
    },
    fieldConfig.validators
    )
    );
    }
    });

    // 6. Add dependents if their dependencies are satisfied (batch)
    dependents.forEach(([fieldName, fieldConfig]) => {
    const depField = fieldConfig.dependsOn.field;
    const depValue = fieldConfig.dependsOn.value;
    const currentDepValue = this.form.get(depField)?.value;
    if (currentDepValue === depValue) {
    const freshConfig = { ...fieldConfig, name: fieldName, options: fieldConfig.options ? [...fieldConfig.options] : [] };
    // Insert after controller
    const controllerIndex = orderedFields.findIndex(f => f.name === depField);
    if (controllerIndex !== -1) {
    orderedFields.splice(controllerIndex + 1, 0, freshConfig);
    } else {
    orderedFields.push(freshConfig);
    }
    if (!this.form.contains(fieldName)) {
    this.form.addControl(
    fieldName,
    new FormControl(
    {
    value: initialData[fieldName] !== undefined
    ? initialData[fieldName]
    : (previousValues[fieldName] !== undefined
    ? previousValues[fieldName]
    : (fieldConfig.isMultiple ? [] : fieldConfig.defaultValue)),
    disabled: fieldConfig.isDisabled
    },
    fieldConfig.validators
    )
    );
    }
    } else {
    if (this.form.contains(fieldName)) {
    this.form.removeControl(fieldName);
    }
    }
    });

    // 7. Update disabled state for all controls (batch)
    orderedFields.forEach(fieldConfig => {
    const fieldName = fieldConfig.name;
    const control = this.form.get(fieldName);
    if (control) {
    if (fieldConfig.isDisabled !== control.disabled) {
    if (fieldConfig.isDisabled) {
    control.disable({ emitEvent: false });
    } else {
    control.enable({ emitEvent: false });
    }
    }
    }
    });

    // 8. Set up listeners for dependency fields (batch)
    orderedFields.forEach(fieldConfig => {
    const fieldName = fieldConfig.name;
    dependents.forEach(([depName, depConfig]) => {
    if (depConfig.dependsOn.field === fieldName) {
    const control = this.form.get(fieldName);
    if (control) {
    this.dependencySubscriptions[fieldName] = control.valueChanges.subscribe(() => {
    this.generateFormFields(role, this.form.getRawValue());
    });
    }
    }
    });
    });

    // 9. Update dynamicFieldsConfig (batch)
    this.dynamicFieldsConfig = [...orderedFields];

    // 10. Only now, call detectChanges (once, at the end)
    this.cdRef.detectChanges();

    // 11. Async select options (do NOT call detectChanges here)
    orderedFields.forEach(fieldConfig => {
    const fieldName = fieldConfig.name;
    if (fieldConfig.type === 'select') {
    let obs$;
    switch (fieldName) {
    case 'connectedDealers':
    case 'primaryDealer':
    case 'dealer':
    obs$ = this.dealerService.getByProvider();
    break;
    case 'connectedProducts':
    case 'productAccess':
    obs$ = this.currentUserType === 'administrator'
    ? this.productService.getAll()
    : this.productService.getByProvider();
    break;
    case 'connectedProvider':
    obs$ = this.providerService.getAll();
    break;
    }
    if (obs$) {
    obs$.subscribe((response: any) => {
    let options: SelectOption[];
    if (fieldName === 'connectedProvider') {
    options = response.data.map((provider: any) => ({
    label: provider.name,
    value: provider.id
    }));
    } else if (fieldName === 'connectedProducts' || fieldName === 'productAccess') {
    options = response.data.map((product: any) => ({
    label: product.name,
    value: product.product_id
    }));
    } else {
    options = response.data.map((dealer: any) => ({
    label: dealer.name,
    value: dealer.id
    }));
    }
    // Update the field in orderedFields and dynamicFieldsConfig
    const idx = orderedFields.findIndex(f => f.name === fieldName);
    if (idx !== -1) {
    orderedFields[idx].options = options;
    this.dynamicFieldsConfig = [...orderedFields];
    // No detectChanges here!
    }
    // Remap and set the value after options are loaded
    const selectControl = this.form.get(fieldName);
    if (selectControl && selectControl.value !== undefined) {
    if (fieldConfig.isMultiple) {
    const optionValues = options.map((o: any) => o.value);
    const mapped = (Array.isArray(selectControl.value) ? selectControl.value : [selectControl.value])
    .map((val: any) => {
    const opt = options.find((o: any) => o.value == val);
    return opt ? opt.value : val;
    })
    .filter((val: any) => optionValues.includes(val));
    selectControl.setValue(mapped, { emitEvent: false });
    } else {
    const opt = options.find((o: any) => o.value == selectControl.value);
    if (opt) {
    selectControl.setValue(opt.value, { emitEvent: false });
    }
    }
    }
    });
    }
    }
    });
    }



    isAllowedForProvider(fieldConfig: any, currentProviderId: number | undefined): boolean {
    if (!fieldConfig.allowedForProviders) return true;
    if (!currentProviderId) return true;
    return fieldConfig.allowedForProviders.includes(currentProviderId ?? 0);
    }

    isAvailableForType(fieldConfig: any, currentUserType: string): boolean {
    if (!fieldConfig.availableForTypes || fieldConfig.availableForTypes.length === 0) return true;
    return fieldConfig.availableForTypes.map((t: string) => t.toLowerCase()).includes(currentUserType);
    }

    private isDependencySatisfied(fieldConfig: any): boolean {
    if (!fieldConfig.dependsOn) return true;
    const depField = fieldConfig.dependsOn.field;
    const depValue = fieldConfig.dependsOn.value;
    const currentDepValue = this.form.get(depField)?.value;
    return currentDepValue === depValue;
    }

    onSubmit() {
    if (this.form.valid) {
    this.formSubmit.emit(this.form.getRawValue());
    } else {
    this.form.markAllAsTouched();
    }
    }
    }


    And its html:

    <form [formGroup]="form">

    @if (form.get('role')) {
    <div class="my-4">
    <app-label forId="role">Brugertype</app-label>
    @if (mode === 'edit') {
    <app-text-input
    formControlName="role"
    id="role"
    [placeholder]="'Brugertype'"
    [messages]="{
    required: 'Brugertype er påkrævet.'
    }"
    ></app-text-input>
    } @else {
    <app-select-search
    class="flex-1"
    formControlName="role"
    [options]="availableRoles"
    placeholder="Vælg brugertype"
    ></app-select-search>
    }
    </div>
    }
    @if (form.get('email')) {
    <div class="my-4">
    <app-label forId="email">Email</app-label>
    <app-email-input
    formControlName="email"
    id="email"
    [disableAutoComplete]="true"
    [messages]="{
    required: 'Email er påkrævet.',
    strictEmail: 'Email er ikke gyldig.'
    }"
    placeholder="example@email.dk"
    ></app-email-input>
    </div>
    }
    @for (field of dynamicFieldsConfig; track field.name) {
    @if (form.get(field.name)) {
    <div class="my-4">
    <app-label [forId]="field.name">{{ field.label }}</app-label>
    @switch (field.type) {
    @case ('select') {
    <app-select-search
    class="flex-1"
    [formControlName]="field.name"
    [options]="field.options"
    [placeholder]="field.placeholder"
    [multiple]="field.isMultiple"
    [messages]="{
    required: field.required ? 'Dette felt er påkrævet.' : ''
    }"
    ></app-select-search>
    }
    @case ('text') {
    <app-text-input
    [formControlName]="field.name"
    [placeholder]="field.placeholder ?? ''"
    [messages]="{
    required: field.required ? 'Dette felt er påkrævet.' : ''
    }"
    ></app-text-input>
    }
    @case ('toggle') {
    <app-toggle-switch
    [id]="field.name"
    [formControlName]="field.name"
    [messages]="{
    required: field.required ? 'Dette felt er påkrævet.' : ''
    }">
    </app-toggle-switch>
    }
    }
    </div>
    }
    }
    </form>


    I have tried using chatGPT 4.1 to help debug it for almost 2 days now, and have not had any success with it.

    I am hoping someone can help me make this work!

    If there is any code or information missing, please let me know, and I will add it.

    Continue reading...

Compartilhe esta Página