AngularのForm(応用)

f:id:ryotah:20171202160340p:plainf:id:ryotah:20171202160319p:plain

これは何か

AngularConnect 2017のセッション「Angular Forms - Advanced Topics」を自分用にまとめたものです。 セッションではCustom Form Controls、ControlValueAccessor、Formを入れ子にする場合のベストプラクティスなどについて話されています。

Angular v5.0.2で動作確認しています。

Custom Form Controls

  • 自作のフォームパーツをつくる
  • ControlValueAccessorを利用する
  • バリデーションが必要な場合はValidatorも利用する
    • 外部から(利用するテンプレートから)requiredなど既存のバリデーションを付与することも可能だが、コンポーネントの機能として必須な条件があれば、内部で定義した方がいい

NG_VALUE_ACCESSORを利用する

@Component({
  // ...
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: FooComponent,
      multi: true
    },
    {
      provide: NG_VALIDATORS,
      useExisting: FooComponent,
      multi: true
    }
  ]
})
export class FooComponent implements ControlValueAccessor, Validator {

  // ControlValueAccessor
  writeValue(obj: any) {
  }
  registerOnChange(fn: any) { 
  }
  registerOnTouched(fn: any) {
  }
  setDisabledState(disabled: boolean) {
  }

  // Validator
  validate(ctrl: AbstractControl) {
  }
}

NgControlを利用する

f:id:ryotah:20180515164825p:plain

  • コンポーネント内でcontrolにアクセスしたい場合(control.errorsを利用したい)
    • [control]=dir.control #dir="ngModel"[control]=form.get('foo') formControlName="foo"でもできるが、毎回書くのは煩わしい
    • NgModelFormControlNameは利用しないでNgControlを利用する(Template-driven、Reactive両方で利用できるようにするため)
  • provide: ...は不要になる
  • @Selfにする意味
export class FooNgControlComponent implements ControlValueAccessor {

  constructor(@Self() public controlDir: NgControl) {
    controlDir.valueAccessor = this;
  }

  ngOnInit() {
    const control = this.controlDir.control;
    const validators = control.validator
      ? [control.validator, Validators.required]
      : Validators.required;
    control.setValidators(validators);
    control.updateValueAndValidity();
  }

  // ControlValueAccessor
  writeValue(obj: any) {
  }
  registerOnChange(fn: any) { 
  }
  registerOnTouched(fn: any) {
  }
  setDisabledState(disabled: boolean) {
  }
}

サンプルコード

メモ

フォームの入れ子

ControlValueAccessorを利用

  • FormGroupの値を利用して、データの受け渡しをする
  • Template-drivenとReactive両方で利用できる
@Component({
  selector: 'app-address',
  template: `
  <div [formGroup]="form">
    <input type="text" formControlName="street">
    <input type="text" formControlName="city">
  </div>`,
  providers: [
    { provide: NG_VALUE_ACCESSOR, /* ... */ },
    { provide: NG_VALIDATORS, /* ... */ },
  ]
})
export class AddressComponent  implements OnInit, ControlValueAccessor {
  form: FormGroup;
  constructor(private fb: FormBuilder) { }

  ngOnInit() {
    this.form = this.fb.group({
      street: '',
      city: ''
    });
  }

  // ControlValueAccessor
  writeValue(obj: any) {
    if (obj) {
      this.form.setValue(obj, { emitEvent: false });
    }
  }
  registerOnChange(fn: any) {
    this.form.valueChanges.subscribe(fn);
  }
  registerOnTouched(fn: any) {
  }
}

Template-driven専用

@Component({
  selector: 'app-address-template',
  template: `
  <div ngModelGroup="address">
    <input type="text" name="street" ngModel>
    <input type="text" name="city" ngModel>
  </div>
    `,
  viewProviders: [
    {
      provide: ControlContainer,
      useExisting: NgForm
    }
  ]
})
export class AddressTemplateComponent {
}

Reactive専用

  • シンプルですぐ実装可能
  • FormGroupに子コンポーネントが自分でコントローラを登録する
    • ちょっと奇妙な書き方に感じる
@Component({
  selector: 'app-address-reactive',
  template: `
  <div formGroupName="address">
    <input type="text" formControlName="street">
    <input type="text" formControlName="city">
  </div>
    `,
  viewProviders: [
    {
      provide: ControlContainer,
      useExisting: FormGroupDirective
    }
  ]
})
export class AddressReactiveComponent {

  constructor(private parent: FormGroupDirective) {
  }

  ngOnInit() {
    // FormGroupを取得
    const form = this.parent.form;

    // 自身で、親のFormGroupにコントローラを登録する
    form.addControl('address', new FormGroup({
      street: new FormControl(''),
      city: new FormControl(''),
    }));
  }
}

サンプルコード

  • angular-form-nested-forms - StackBlitz
    • AddressComponent
      • ControlValueAccessorを利用
      • Template-driven、Reactive両方で利用可能
    • AddressTemplateComponent
      • Template-drivenで利用可能
    • AddressReactiveComponent
      • Reactive両方で利用可能

メモ

Error Aggregator

  • フォーム内の各コントローラのエラーを集約して表示させたい場合
  • formをprojectionするのはさせよう
    • Template-driven、Reactive、どちらかでしか利用できないような設計になる
    • 難しくなるし、バグが発生しやすくなる
    • もうすこし良い設計があるはず
@Component({
  selector: 'error-aggregator',
  template: `
  <div *ngFor="let error of errors">{{error | json}}</div>`,
})
export class ErrorAggregatorComponent implements OnInit {
  @Input() form: FormGroup;
  errors;
  constructor() { }
  ngOnInit() {
    // NgFormを間違って渡していないか確認
    if (this.form instanceof FormGroup) {
      this.form.statusChanges.subscribe(val => {
        this.errors = this.reduceErrors();
      });
    }
  }  
  private reduceErrors() {
    return pickErrors(this.form);
  }
}

// for demo
function pickErrors(formGroup: FormGroup) {
  debugger;
  const keys = Object.keys(formGroup.controls);
  return keys.map(key => {
    const control = formGroup.controls[key];
    if (control instanceof FormControl) {
      return control.errors;
    } else if (control instanceof FormGroup) {
      return pickErrors(control);
    }
  });
}

サンプルコード

参考

メモ

  • 以前のコード
// ...
@Input() model?: string;
@Input() form: NgForm;
@ViewChild('ngModel') ngModel: NgModel;
ngOnInit() {
  this.form.addControl(this.ngModel);
}
// ...