web-dev-qa-db-ja.com

Angular反応性フォームカスタムコントロール非同期検証

これがトリックです:

  • カスタムコントロールとして使用される実装されたControlValueAccessorインターフェイスを持つコンポーネントがあります。
  • このコンポーネントは、いくつかの反応フォーム内でFormControlとして使用されます。
  • このカスタムコントロールには非同期バリデーターがあります。

問題:

ControlValueAccessorインターフェースのメソッドvalidate()は、値が変更された直後に呼び出し、非同期バリデーターを待機しません。もちろん、コントロールは無効で保留中です(検証が進行中のため)。メインフォームも無効で保留中になります。すべてが大丈夫です。

だが。非同期バリデーターが検証を終了してnullを返すと(値は有効であることを意味します)、カスタムコントロールは有効になり、ステータスも有効になりますが、値アクセサーのvalidate()が再度呼び出されていないため、親はまだ無効から保留中のステータスになっています。

Validate()メソッドからオブザーバブルを返そうとしましたが、メインフォームはそれをエラーオブジェクトとして解釈します。

回避策が見つかりました:非同期バリデーターが検証を終了したときに、カスタムコントロールから変更イベントを伝達します。メインフォームにvalidate()メソッドを再度呼び出し、正しい有効なステータスを取得するように強制しています。しかし、汚れて荒れているように見えます。

質問は次のとおりです:親フォームを子カスタムコントロールから非同期バリデーターで管理できるようにするにはどうすればよいですか?同期バリデーターとの相性は抜群です。

すべてのプロジェクトコードはここにあります: https://stackblitz.com/edit/angular-fdcrbl

メインフォームテンプレート:

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

メインフォームクラス:

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("")
    });
  }
}

カスタムの子コントロールテンプレート:

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

カスタムの子コントロールクラス:

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
}
5
vbilenko

私はさまざまなアプローチやトリックを試しました。しかし、AndreiGătejがメインフォームについて言及したように、子コントロールの変更を解除します。

私の目標は、カスタムコントロールを独立させ、検証をメインフォームに移動しないことです。それは私に白髪のペアを要しましたが、私は許容できる回避策を見つけたと思います。

子コンポーネントの検証関数内のメインフォームから制御を渡し、そこで有効性を管理する必要があります。実際には、次のようになります。

  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 };
      }),
    );
  }
1
vbilenko

問題は、_childForm.statusChanges_が発生するまでに、非同期バリデーターのサブスクリプションが既にキャンセルされていることです。

これは、_childForm.valueChanges_がbefore_childForm.statusChanges_を発行するためです。

_childForm.valueChanges_が発生すると、登録されているonChangedコールバック関数が呼び出されます。

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

これにより、FormControl(controlChild)はその値を更新します

_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});
  /* ... */
}
_

updateValueAndValidityに到達することを意味します:

_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);
  }
}
_

私が思いついたアプローチでは、カスタムコンポーネントがControlValueAccesorおよびAsyncValidatorインターフェースの実装をスキップできます。

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 { /* ... */ }
_

ここでの核心は、viewProviders内に_{ provide: ControlContainer, useExisting: FormGroupDirective }_を提供することです。

これは、FormControlName内で、_@Host_デコレータを使用して親の抽象コントロールが取得されるためです。これにより、取得する_@Host_デコレーター(この場合はviewProviders)で宣言されている依存関係をControlContainer内で指定できます。
FormControlNameは次のように取得します@Optional() @Host() @SkipSelf() parent: ControlContainer

StackBlitz

1
Andrei Gătej