6

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
}
vbilenko
  • 331
  • 1
  • 8

2 Answers2

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 };
      }),
    );
  }
vbilenko
  • 331
  • 1
  • 8
  • 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: https://stackblitz.com/edit/angular-bnub6s. – andreivictor Jun 12 '20 at 19:11
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.

Andrei Gătej
  • 8,356
  • 1
  • 7
  • 24