Search code examples
angulartypescriptcontrolvalueaccessor

Run ControlValueAccessor validate after updating and validating child components


I'm dealing with a tough problem in Angular. I want to create a custom form component that "bundles" several child input elements and is valid when all children are valid.

I thought I'd simply create a component implementing both the ControlValueAccessor and Validator interfaces. I'd inject the inputs with the ViewChildren decorator as NgModels so I could loop over them and collect any validation errors in the component's validate method. Here is a sample implementation for an address input component I made that does this for two inputs (street and street number) which both require a value :

import { AfterViewInit, Component, forwardRef, OnInit, QueryList, ViewChildren } from '@angular/core';
import { AbstractControl, ControlValueAccessor, NG_VALIDATORS, NgModel, NG_VALUE_ACCESSOR, ValidationErrors, Validator } from '@angular/forms';
import { Address } from './address';

@Component({
    selector: `address-input`,
    template: `
        <label for="street">Street</label>
        <input id="street" name="street" type="text" [(ngModel)]="Street" (blur)="onTouchedAField()" required /><br />
        <label for="number">Number</label>
        <input id="number" name="number" type="number" [(ngModel)]="Number" (blur)="onTouchedAField()" required />`,
    providers: [
        {
            provide: NG_VALUE_ACCESSOR,
            useExisting: AddressInputComponent,
            multi: true,
        },
        {
            provide: NG_VALIDATORS,
            useExisting: AddressInputComponent,
            multi: true,
        },
    ],
})
export class AddressInputComponent implements ControlValueAccessor, Validator {
    @ViewChildren(NgModel) public validatedFields!: QueryList<NgModel>;

    private address: Address = new Address(null, null);

    private onFormCtrlChanged!: (_: any) => void;
    private onFormCtrlTouched!: (_: any) => void;

    public get Street(): string | null {
        if (this.address) {
            return this.address.Street;
        } else {
            return null;
        }
    }
    public set Street(val: string) {
      this.address = new Address(val, this.address.Number);

        this.emitChanged();
    }

    public get Number(): number | null {
        if (this.address) {
            return this.address.Number;
        } else {
            return null;
        }
    }
    public set Number(val: number | null) {
        this.address = new Address(this.address.Street, val);

        this.emitChanged();
    }

    public onTouchedAField(): void {
        this.emitTouched();
    }

    public writeValue(newAddress: Address): void {
        if (newAddress !== undefined) {
            this.address = newAddress;
        }
    }

    public registerOnChange(fn: (_: any) => void): void {
        this.onFormCtrlChanged = fn;
    }

    public registerOnTouched(fn: (_: any) => void): void {
        this.onFormCtrlTouched = fn;
    }

    public validate(control: AbstractControl): ValidationErrors | null {
        let validationErrors: ValidationErrors | null = null;
        this.validatedFields.forEach((ngm: NgModel) => {
            console.log(`ValidationErrs for ${ngm.name} with value ${ngm.value}`, ngm.errors);
            if (ngm.errors !== null) {
                if (validationErrors === null)
                    validationErrors = {};
                validationErrors[ngm.name] = ngm.errors;
            }
        });

        console.log(`validationErrors `, validationErrors);
        return validationErrors;
    }

    private emitChanged(): void {
        this.onFormCtrlChanged(this.address);
    }

    private emitTouched(): void {
        this.onFormCtrlTouched(this.address);
    }
}

This mostly works fine when I embed the address component into a form like this:

<form (ngSubmit)="onSubmit()" #myForm="ngForm">
    <address-input name="address" [(ngModel)]="address" #addressInput="ngModel"></address-input>

    <button type="submit">SUBMIT</button>
</form>

The only, but an annoying, issue is that when the address input component will be filled in correctly through a model binding the address input component will nevertheless remain in an invalid state.

I.e. when the app component containing the form that contains the custom form component defines the address field that it binds to the custom form component like so:

public address: Address = new Address('Baker Street', 123);

the custom form component should be valid but remains invalid.

When I then add a letter or digit to a field, for example, the custom form component immediately becomes valid.

After some investigation I noticed that this is because Angular calls the validate method of the Validator interface immediately after it calls the writeValue method of the ControlValueAccessor interface on the custom form component. That way, the values of the child inputs of the custom form component and their validation state are not updated yet when Angular calls the validate method on the custom form component.

To solve my issue, I am wondering if there is any way to force Angular to update the value and validation state of ngModels or to run validate again when child inputs/components have been updated. Or if there is any other way to make this work. More broadly, I am also wondering if there is a better way to implement a form component that is valid when all its children are, because I feel that fixing this issue is going to require some "hacks" or inefficient code.

You can find a Stackblitz here containing a simple reproduction of the issue.

Thanks in advance for your time and help, Joshua


Solution

  • You code would be much simpler if you use ReactiveForms, in ReactiveForms you source of truth is the model and you always have access to the state of your individual control or group of controls without scanning component with @ViewChildren, here is very good example of how this is implemented from angular docs https://angular.io/guide/form-validation#cross-field-validation