Reputation: 645
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:
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.
Upvotes: 1
Views: 102