Angular reactive form custom control async validation
Asked Answered
G

7

11

UPDATE: Issue with async validation successfully solved. But there is another issue with initial validation state. See latest answer.

Here is the trick:

  • Have component with implemented ControlValueAccessor interface to be used as custom control.
  • This component used as FormControl inside some reactive form.
  • This custom control has async validator.

The problem:

Method validate() from ControlValueAccessor interface calls right after value change and do not wait async validator. Of course control is invalid and pending (because validation in progress) and main form also goes to be invalid and pending. Everything is okay.

But. When async validator finish to validate and return null (means value is valid) then custom control going to be valid and status changes to valid also, but parent from still invalid with pending status because validate() from value accessor haven't called again.

I have tried to return observable from the validate() method, but main form interprets it as error object.

I found workaround: propagate change event from custom control when async validator finish to validate. It's forcing main form to call validate() method again and get correct valid status. But it looks dirty and rough.

Question is: What have to be done to make parent form be managed by async validator from child custom control? Must say it works great with sync validators.

All project code can be found here: https://stackblitz.com/edit/angular-fdcrbl

Main form template:

<form [formGroup]="mainForm">
    <child-control formControlName="childControl"></child-control>
</form>

Main form class:

import { Component, OnInit } from "@angular/core";
import { FormBuilder, FormGroup } from "@angular/forms";

@Component({
  selector: "my-app",
  templateUrl: "./app.component.html"
})
export class AppComponent implements OnInit {
  mainForm: FormGroup;

  constructor(private formBuilder: FormBuilder) {}

  ngOnInit() {
    this.mainForm = this.formBuilder.group({
      childControl: this.formBuilder.control("")
    });
  }
}

Custom child control template:

<div [formGroup]="childForm">
    <div class="form-group">
        <label translate>Child control: </label>
        <input type="text" formControlName="childControl">
    </div>
</div>

Custom child control class:

import { Component, OnInit } from "@angular/core";
import { AppValidator } from "../app.validator";
import {
  FormGroup,
  AsyncValidator,
  FormBuilder,
  NG_VALUE_ACCESSOR,
  NG_ASYNC_VALIDATORS,
  ValidationErrors,
  ControlValueAccessor
} from "@angular/forms";
import { Observable } from "rxjs";
import { map, first } from "rxjs/operators";

@Component({
  templateUrl: "./child-control.component.html",
  selector: "child-control",
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: ChildControlComponent,
      multi: true
    },
    {
      provide: NG_ASYNC_VALIDATORS,
      useExisting: ChildControlComponent,
      multi: true
    }
  ]
})
export class ChildControlComponent
  implements ControlValueAccessor, AsyncValidator, OnInit {
  childForm: FormGroup;

  constructor(
    private formBuilder: FormBuilder,
    private appValidator: AppValidator
  ) {}

  ngOnInit() {
    this.childForm = this.formBuilder.group({
      childControl: this.formBuilder.control(
        "",
        [],
        [this.appValidator.asyncValidation()]
      )
    });
    this.childForm.statusChanges.subscribe(status => {
      console.log("subscribe", status);
    });
  }

  // region CVA
  public onTouched: () => void = () => {};

  writeValue(val: any): void {
    if (!val) {
      return;
    }
    this.childForm.patchValue(val);
  }

  registerOnChange(fn: () => void): void {
    this.childForm.valueChanges.subscribe(fn);
  }

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

  setDisabledState?(isDisabled: boolean): void {
    isDisabled ? this.childForm.disable() : this.childForm.enable();
  }

  validate(): Observable<ValidationErrors | null> {
    console.log('validate');
    // return this.taxCountriesForm.valid ? null : { invalid: true };
    return this.childForm.statusChanges.pipe(
      map(status => {
        console.log('pipe', status);
        return status == "VALID" ? null : { invalid: true };
      }),
    );
  }
  // endregion
}
Gambrinus answered 12/12, 2019 at 22:11 Comment(3)
Did you find a solution for this?Andrewandrewes
@AndreiGătej nope, unfortunately question still open :(Gambrinus
So, is not available any solution in order to have the async validator in the child component right? Is there any official issues on the angular github repo ?Medici
G
4

Everything is easy with such validation. Just need to add first()/take(1) at the end to close observable and finish subscription.

 validate(): Observable<ValidationErrors | null> {
    return this.childForm.statusChanges.pipe(
      filter((status) => status !== 'PENDING'),
      map(status => {
        return status == "VALID" ? null : { invalid: true };
      }),
      first(),
    );
  }

But there is another issue.

statusChange haven't propagate status changes on init component. Validate method have been called as expected, statusChange observable returned, but nothing happened when async validation finished. As a result parent form still in PENDING state.

But if you do that manually (just change the input value) statusChange inside validate method triggers correctly and returns correct status.

It would be nice to get some help with this.

Here is updated code example: https://stackblitz.com/edit/angular-cva-async-validation

Gambrinus answered 22/9, 2021 at 12:12 Comment(2)
This issue is driving me nuts for a long time now. I get your example to work when I add updateValueAndValidity to your writeValue method like so: writeValue(val: any): void { if (!val) { return; } this.childForm.patchValue(val); setTimeout(() => { this.childForm.updateValueAndValidity() }, 10) } But this is still really hacky.Wheeling
@Wheeling yes, there is a trick. Status hasn't emit change on initial form value, because template is not ready yet and no subscription applies. Timeout can solve this problem, but I would recommend to use ChangeDetectorRef for changes detection and after that apply new value. You can see how it works in example stackblitz.com/edit/… row 23Gambrinus
G
2

I have tried different approaches and tricks. But as Andrei Gătej mentioned main form unsubscribe from changes in child control.

My goal was to keep custom control independent and do not move validation to main form. It cost me a pair of gray hairs, but I think I found acceptable workaround.

Need to pass control from main form inside validation function of child component and manage validity there. In real life it might looks like:

  validate(control: FormControl): Observable<ValidationErrors | null> {
    return this.childForm.statusChanges.pipe(
      map(status => {
        if (status === "VALID") {
          control.setErrors(null);
        }
        if (status === "INVALID") {
          control.setErrors({ invalid: true });
        }
        // you still have to return correct value to mark as PENDING
        return status == "VALID" ? null : { invalid: true };
      }),
    );
  }
Gambrinus answered 7/2, 2020 at 23:6 Comment(1)
Implemented your approach, but the parent form is in pending state on init (till it is not touched). It works perfectly after. It seems that childForm.statusChanges does not emit a value when first changing from Pending to Valid. Example here: stackblitz.com/edit/angular-bnub6s.Perkoff
M
2

My solution using ControlValueAccessor:

Include the async provider. You should only need to add the NG_ASYNC_VALIDATORS because it is an extension of NG_VALIDATORS. If you add both it won't work because one assumes an Observable as return and the other just an object.

providers: [
    {
        provide: NG_VALUE_ACCESSOR,
        useExisting: <YourComponent>,
        multi: true
    },
    {
        provide: NG_ASYNC_VALIDATORS,
        useExisting: forwardRef(() => <YourComponent>),
        multi: true
    }
],

And then I solved the issue by listening to the statusChanges but using startWith to give the observable the initial form value:

public validate(control: AbstractControl): Observable<ValidationErrors | null> {
    if (!this.form || this.form.valid) {
        return of(null);
    }

    return this.form.statusChanges.pipe(
        startWith(this.form.status),  // start with the current form status
        filter((status) => status !== 'PENDING'),
        take(1), // We only want one emit after status changes from PENDING
        map((status) => {
            return this.form .valid ? null : { <your-form-name>: { valid: false } }; // I actually loop through the form and collect the errors, but for validity just return this works fine
        })
    );
}
Mcgill answered 17/5, 2022 at 8:12 Comment(0)
R
1

The problem is that by the time childForm.statusChanges emits, the subscription for the async validator will have already been cancelled.

This is because childForm.valueChanges emits before childForm.statusChanges.

When childForm.valueChanges emits, the registered onChanged callback function will be called:

registerOnChange(fn: () => void): void {
  this.childForm.valueChanges.subscribe(fn);
}

Which will cause the FormControl(controlChild) to update its value

function setUpViewChangePipeline(control: FormControl, dir: NgControl): void {
  dir.valueAccessor !.registerOnChange((newValue: any) => {
    /* ... */

    if (control.updateOn === 'change') updateControl(control, dir);
  });
}

// Update the MODEL based on the VIEW's value
function updateControl(control: FormControl, dir: NgControl): void {
  /* ... */

  // Will in turn call `control.setValueAndValidity`
  control.setValue(control._pendingValue, {emitModelToViewChange: false});
  /* ... */
}

meaning that updateValueAndValidity will be reached:

updateValueAndValidity(opts: {onlySelf?: boolean, emitEvent?: boolean} = {}): void {
  this._setInitialStatus();
  this._updateValue();

  if (this.enabled) {
    this._cancelExistingSubscription(); // <- here the existing subscription is cancelled!
    (this as{errors: ValidationErrors | null}).errors = this._runValidator(); // Sync validators
    (this as{status: string}).status = this._calculateStatus(); // VALID | INVALID | PENDING | DISABLED

    if (this.status === VALID || this.status === PENDING) {
      this._runAsyncValidator(opts.emitEvent);
    }
  }

  if (opts.emitEvent !== false) {
    (this.valueChanges as EventEmitter<any>).emit(this.value);
    (this.statusChanges as EventEmitter<string>).emit(this.status);
  }

  if (this._parent && !opts.onlySelf) {
    this._parent.updateValueAndValidity(opts);
  }
}

The approach I came up with allows your custom component to skip implementing the ControlValueAccesor and AsyncValidator interfaces.

app.component.html

<form [formGroup]="mainForm">
    <child-control></child-control>
</form>

### app.component.ts

  ngOnInit() {
    this.mainForm = this.formBuilder.group({
      childControl: this.formBuilder.control("", null, [this.appValidator.asyncValidation()])
    });
 }

child-control.component.html

<div class="form-group">
  <h4 translate>Child control with async validation: </h4>
  <input type="text" formControlName="childControl">
</div>

child-control.component.ts

@Component({
  templateUrl: "./child-control.component.html",
  selector: "child-control",
  providers: [
  ],
  viewProviders: [
    { provide: ControlContainer, useExisting: FormGroupDirective }
  ]
})
export class ChildControlComponent
  implements OnInit { /* ... */ }

The crux here is to provide { provide: ControlContainer, useExisting: FormGroupDirective } inside viewProviders.

This is because inside FormControlName, the parent abstract control is retrieved with the help of the @Host decorator. This allows you to specify inside viewProviders which dependency that is declared with the @Host decorator you want to get(in this case, ControlContainer).
FormControlName retrieves it like this @Optional() @Host() @SkipSelf() parent: ControlContainer

StackBlitz.

Remind answered 1/2, 2020 at 16:29 Comment(0)
G
0

Form may ignore initial value and got stuck on PENDING until value change.

It happens because status hasn't emit change on initial form value, because template is not ready yet and no subscription applies.

This issue can be solved with ChangeDetectorRef. Define the form, detect changes and apply new value after that. You can see how it works in example https://stackblitz.com/edit/angular-cva-async-validation?file=src%2Fapp%2Fapp.component.ts row 23

this.mainForm = this.formBuilder.group({
  mainControl: this.formBuilder.control(''),
});

// require to set value after view ready
// otherwise initial state of form will freeze on PENDING
this.changeDetector.detectChanges();
this.mainForm.setValue({
    mainControl: {
        childControl: 'test',
    },
});
Gambrinus answered 22/10, 2021 at 9:36 Comment(0)
A
0

For the sake of completeness I add my other answer here as well. The approach is little bit different since it works well with a Directive on a custom child control of a form.

I placed there as well the link to a fully working Stackblitz.

Abysmal answered 8/11, 2021 at 14:29 Comment(0)
V
0

Thought I'd post another solution that I ended up using after trying various other solutions posted here, in case it may help someone.

In my case I had multiple nested complex CVA components (4 levels deep), each with their own validation rules, and the parent's validation state was not picking up the child component's validation state when the form was initialised (once the form is edited everything was fine). This was due to the parent CVA Component's validate function running before the FormGroup/FormArray being checked has had its controls validated by the child CVA Components.

To keep the parent Component's validity status up to date with its children, keep track of what the last reported valid state was up to the parent. Then implement the DoCheck interface and if the last reported valid state does not match the current state call onChanges to notify the parent Component.

  ngDoCheck() {
    if (this.lastReportedValidState !== this.form.valid) {
      this.onChanged(this.form.value);
    }
  }

  registerOnChange(fn: any): void {
    this.onChanged = fn;
    this.form.valueChanges.subscribe(fn);
  }

  validate(control: AbstractControl): ValidationErrors | null {
    this.lastReportedValidState = this.form.valid;
    return this.form.invalid ? { invalid: true } : null;
  }
Vague answered 2/12, 2021 at 1:32 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.