Angular dynamic forms: Is a bad practice to update values programmatically?

2.3k views Asked by At

I have implemented a generic dynamic form component:

@Component({
    selector: 'dynamic-form',
    templateUrl: './dynamic-form.component.html'
})
export class DynamicFormComponent implements OnInit, OnChanges {

    @Input() inputs: InputBase<any>[] = [];
    @Input() submitLabel: string;
    @Input() globalValidator: ValidatorFn | undefined;      

    @Output() onSubmit: EventEmitter<any> = new EventEmitter<any>();
    @Output() onChanges: EventEmitter<any> = new EventEmitter<any>();
    @Output() onValid: EventEmitter<boolean> = new EventEmitter<boolean>();

    private wasValid: boolean = false;
    form: FormGroup;

    constructor(private fb: FormBuilder) { }

    ngOnInit() {
        //Let's get the needed controls to create a FormGroup
        this.form = this.generateFormGroup();
    }

    ngOnChanges() {
        this.form = this.generateFormGroup();
    }

    private generateFormGroup(): FormGroup {
        let group: any = {};
        this.inputs && this.inputs.forEach(ib => {
                ib.addtoFormGroup(group);
            });

        let form: FormGroup;
        if (this.globalValidator) {
            form = this.fb.group(group, { validator: this.globalValidator });
        } else {
            form = this.fb.group(group);
        }

        form.valueChanges.subscribe(data => {
            if (this.form.valid !== this.wasValid) {
                this.wasValid = this.form.valid;
                this.onValid.emit(this.wasValid);
            }
            this.onChanges.emit(this.form.value);
        });
        return form;
    }
  submit() {
        this.onSubmit.emit(this.form.value);
    }
    isValid(): boolean {
        return this.form.valid;
    }
    getValue() {
        return this.form.value;
    }
}

The template is the following:

    <form *ngIf="form" role="form" class="form-horizontal" (ngSubmit)="submit()" [formGroup]="form">
        <div class=" row ibox-content">
            <div class="col-md-12">
                <div *ngFor="let input of inputs" class="form-group">
                    <df-input [input]="input" [form]="form" [options]="input.options"></df-input>
                </div>
            </div>
            <div class="row col-md-12">
                <button class="btn btn-primary pull-right" type="submit" [disabled]="!form.valid">{{submitLabel| translate}}</button>
            </div>
        </div>
    </form>

As you can see, this component receives a list of items of several classes which extend InputBase and uses them to generate df-input components: The only interesting part of this class for my question is the following method, which helps to fill the object that will be used to create the FormGroup:

    addtoFormGroup(groupConf: {[key:string]:any}) {
        if (groupConf[this.key]) {
            throw new Error('The form already has an input with the same name: '+ this.key);
        }
        groupConf[this.key]=[this.value,this.validators];   
    }

Now the df-input component is as follows (simplified):

    @Component({
        selector: 'df-input',
        templateUrl: './input.component.html',
        styleUrls: ['./input.component.css']
    })
    export class InputComponent implements OnInit, OnChanges,DoCheck {

        @Input() input: InputBase<any>;
        @Input() form: FormGroup;

        private wasValid: boolean = false;
        differ: any;

        constructor(private differs: KeyValueDiffers) {

        }

        ngOnInit() {
            this.differ = this.differs.find(this.input).create();
            if (this.input.controlType === 'dropdown') {
                let configuration: InputDropdownConfiguration<any>= (this.input as InputDropdownConfiguration<any>);
                if (configuration.options && configuration.options.length===1) {
                    if (this.form) {
                        (this.form.controls[this.input.key] as AbstractControl).setValue(configuration.options[0].value)
                    }
                }
            }
        }

        ngOnChanges() {
            this.ngOnInit();
        }

        ngDoCheck() {
            var changes = this.differ.diff(this.input);
        }

        get isValid(): boolean {
            if (this.form && this.form.controls && this.form.controls[this.input.key]) {
                return this.form.controls[this.input.key].valid || !this.form.controls[this.input.key].touched;
            }
            return true;
        }

    }

And its template

    <div [formGroup]="form" class="form-group" [class.has-error]="!isValid">

        <label *ngIf="input.label" [attr.for]="input.key" class="control-label col-sm-{{labelWidth}}">
            <ng-container *ngIf="input.isRequired">* </ng-container>{{input.label | translate}}
        </label>

        <div [ngSwitch]="input.controlType" class="col-sm-{{inputWidth}}">

            <input *ngSwitchCase="'text'" class="form-control" [formControlName]="input.key" [type]="input.type"
            [name]="input.key" [readonly]="input.readonly" placeholder="{{input.placeholder |translate }}">

            <select *ngSwitchCase="'dropdown'" [formControlName]="input.key" class="form-control">
                <option *ngFor="let opt of options" [selected]="input.value == opt.value" value="{{opt.value}}">{{opt.text | translate}}</option>
            </select>

        </div>
        <div class="help-block col-md-10 col-md-offset-1" *ngIf="!isValid" [hidden]="!isValid">{{input.errorMsg | translate}}</div>
    </div>

This is working, but I'm not sure how to extend it for my next requirement: I need a <select> element which has always a final "Other" option. If user selects this option, then an input text should appear and more information could be entered.

That means two elements, but I'd like to then to behave as only one: if the "other" option is selected, then the form model should ignore the select and take the input text value, but I don't want to add both elements to the formGroup. I am thinking about not adding the attribute [formControlName]="input.key" to any of the elements but programatically, when the selected option changes, check if it is "Other" and then activate the input field. In any case, I will add the new value to the form with

form.controls[input.key].setValue(<new_value>)

Is this a bad practice? I have faced a lot of issues updating the state of my components and I am trying to keep the smart-dumb components approach, but it is not an easy task for me

1

There are 1 answers

0
Lansana Camara On BEST ANSWER

You may want to create a Validation service or something similar, and put all of your custom validators in there.

For instance, this is something I have:

import { Injectable } from '@angular/core';
import { FormGroup, AbstractControl } from '@angular/forms';

import { shouldMatch } from './should-match';

@Injectable()
export class Validation {
    shouldMatch(...props: string[]) {
        return (group: FormGroup): any => shouldMatch(group, ...props);
    }
}

should-match.ts:

import { FormGroup } from '@angular/forms';

export function shouldMatch(group: FormGroup, ...props: string[]): any {
    const ctrls = group.controls;
    const len = props.length;

    for (let i = 1; i < len; i++) {
        if (ctrls[props[0]]) {
            if (ctrls[props[0]].value !== ctrls[props[i]].value) {
                return {invalid: true};
            }
        } else {
            throw new Error(`The property '${props[0]}' passed to 'shouldMatch()' was not found on the form group.`);
        }
    }

    return null;
}

And I use it in a place where I want two passwords to match (it takes in properties as strings which represents the form control names in your form group that should all have the same value):

this.form = this.fb.group({
    paswords: this.fb.group({
        password: [null, Validators.required],
        passwordConfirmation: [null, Validators.required]
    }, {validator: this.validation.shouldMatch('password', 'passwordConfirmation')}
})