1

I would like to render an angular form dynamically, based on a json schema object that describes the fields. I would like to render multiple types of fields, but for this example I will only use text fields. The schema object could look like this:

fields: any[] = [
    {
      key: 'field1',
      label: 'Label 1',
      type: 'text',
      required: true
    },
    {
      key: 'field2',
      label: 'Label 2',
      type: 'text',
      required: true
    }
]

Given the schema above, 2 text fields should be rendered

main.component.html
<form [formGroup]="form">
    <span *ngFor="let field of fields">
        <app-dynamic-form-field [field]="field" [formControlName]="field.key"></app-dynamic-form-field>
        <br>
    </span>
</form>
main.component.ts
ngOnInit(): void {
    const formGroup: any = {};
    for(let field of this.fields) {
      formGroup[field.key] = [null];
    }

    this.form = this.fb.group(formGroup);

    this.form.valueChanges.subscribe(val => {
      console.log(this.form.value);
    })
  }
dynamic-form-field.component.html
<app-text-field *ngIf="field.type == 'text'" [field]="field" [formControl]="formControl"></app-text-field>

<app-number-field *ngIf="field.type == 'number'" [field]="field" [formControl]="formControl"></app-number-field>

<app-select-field *ngIf="field.type == 'select'" [field]="field" [formControl]="formControl"></app-select-field>
dynamic-form-field.component.ts
import { Component, Input, OnInit } from '@angular/core';
import { AbstractControl, ControlValueAccessor, FormBuilder, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator, Validators } from '@angular/forms';

@Component({
  selector: 'app-dynamic-form-field',
  templateUrl: './dynamic-form-field.component.html',
  styleUrls: ['./dynamic-form-field.component.scss'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      multi: true,
      useExisting: DynamicFormFieldComponent
    },
    {
      provide: NG_VALIDATORS,
      useExisting: DynamicFormFieldComponent,
      multi: true
    }
  ]
})
export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor, Validator {

  @Input({required: true})
  field: any;

  formControl!: FormControl;

  onChange = (value:any) => {};
  onTouched = () => {};
  onValidationChange = () => {};

  constructor(private fb: FormBuilder) {}

  ngOnInit(): void {
    this.formControl = new FormControl();

    this.formControl.valueChanges.subscribe(val => {
      this.onTouched();
      this.onChange(val);
      this.onValidationChange();
    })
  }


  // Control Value Accessor
  writeValue(obj: any): void {
    this.formControl.setValue(obj, {emitEvent: false});
  }

  registerOnChange(fn: any): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }

  setDisabledState(isDisabled: boolean): void {
    if(isDisabled) {
      this.formControl.disable({emitEvent: false});
    }
    else {
      this.formControl.enable({emitEvent: false});
    }
  }

  // Validator
  validate(control: AbstractControl<any, any>): ValidationErrors | null {
    if(this.formControl.valid) return null;
    return {error: true};
  }
  registerOnValidatorChange(fn: () => void): void {
    this.onValidationChange = fn;
  }

}
text-field.component.html
<mat-form-field>
    <input matInput
        type="text"
        [placeholder]="field.label"
        [formControl]="formControl">
</mat-form-field>
text-field.component.ts
import { Component, Input, OnInit } from '@angular/core';
import { AbstractControl, ControlValueAccessor, FormBuilder, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator, Validators } from '@angular/forms';

@Component({
  selector: 'app-text-field',
  templateUrl: './text-field.component.html',
  styleUrls: ['./text-field.component.scss'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      multi: true,
      useExisting: TextFieldComponent
    },
    {
      provide: NG_VALIDATORS,
      useExisting: TextFieldComponent,
      multi: true
    }
  ]
})
export class TextFieldComponent implements OnInit, ControlValueAccessor, Validator {

  @Input({required: true})
  field: any;

  formControl!: FormControl;

  onChange = (value:any) => {};
  onTouched = () => {};
  onValidationChange = () => {};

  ngOnInit(): void {
    this.formControl = new FormControl<string>('');

    if(this.field.required) {
      this.formControl.addValidators(Validators.required);
    }

    this.formControl.valueChanges.subscribe(val => {
      this.onTouched();
      this.onChange(val);
      this.onValidationChange();
    })
  }

  writeValue(obj: any): void {
    this.formControl.setValue(obj, {emitEvent: false});
  }

  registerOnChange(fn: any): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }

  setDisabledState(isDisabled: boolean): void {
    if(isDisabled) {
      this.formControl.disable();
    }
    else {
      this.formControl.enable();
    }
  }

  // Validator
  validate(control: AbstractControl<any, any>): ValidationErrors | null {
    if(this.formControl.valid) return null;
    return {error: true};
  }
  registerOnValidatorChange(fn: () => void): void {
    this.onValidationChange = fn;
  }
}

So, basically, I would like to delegate the dynamic nature of the field to a "factory" component called DynamicFormFieldComponent. This works fine, but when the form is rendered, I get the following error:

main.ts:5 ERROR Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value for 'ng-untouched': 'true'. Current value: 'false'. Expression location: MainComponent component. Find more at https://angular.io/errors/NG0100
    at throwErrorIfNoChangesMode (core.mjs:11622:11)
    at bindingUpdated (core.mjs:14851:17)
    at checkStylingProperty (core.mjs:18266:32)
    at Module.ɵɵclassProp (core.mjs:18174:5)
    at NgControlStatus_HostBindings (forms.mjs:65:104)
    at processHostBindingOpCodes (core.mjs:11853:21)
    at refreshView (core.mjs:13544:9)
    at detectChangesInView (core.mjs:13663:9)
    at detectChangesInEmbeddedViews (core.mjs:13606:13)
    at refreshView (core.mjs:13522:9)

When I skip the inbetween DynamicFormComponent and instead render the fields in the main component, this error is not thrown. I have found the following solutions, which both seem too hacky to me and I would like to avoid them:

  1. Add a call to detect changes in main component inside ngAfterViewChecked
  2. use changeDetection: ChangeDetectionStrategy.OnPush inside main components @Component tag

EDIT: Also, the whole thing works fine, I just can't get rid of the error. Adding enableProdMode() in main.ts makes the error go away, but this seems like a hack too.

1
  • I have found that using [formControl] on the inside of custom controls can produce unexpected results. I usually implement my custom controls by binding directly to the [value] and (input) instead. In my opinion, using a FormControl that is disconnected from a form is kind of strange and possibly an anti-pattern. Also, implementing Validator is completely unnecessary if you don't have custom logic to add inside of your custom control. All the validation will be done at the parent form level. Commented Jun 21, 2024 at 22:55

0

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.