Can I access to formControl of my custom ControlVa

2020-02-24 12:44发布

问题:

I would like to create a custom form element with ControlValueAccessor interface in Angular 2+. This element would be a wrapper over a <select>. Is it possible to propagate the formControl properties to the wrapped element? In my case, the validation state is not getting propagated to nested select as you can see on the attached screenshot.

My component is available as following:

  const OPTIONS_VALUE_ACCESSOR: any = {
  multi: true,
  provide: NG_VALUE_ACCESSOR,
  useExisting: forwardRef(() => OptionsComponent)
  };

  @Component({
  providers: [OPTIONS_VALUE_ACCESSOR], 
  selector: 'inf-select[name]',
  templateUrl: './options.component.html'
  })
  export class OptionsComponent implements ControlValueAccessor, OnInit {

  @Input() name: string;
  @Input() disabled = false;
  private propagateChange: Function;
  private onTouched: Function;

  private settingsService: SettingsService;
  selectedValue: any;

  constructor(settingsService: SettingsService) {
  this.settingsService = settingsService;
  }

  ngOnInit(): void {
  if (!this.name) {
  throw new Error('Option name is required. eg.: <options [name]="myOption"></options>>');
  }
  }

  writeValue(obj: any): void {
  this.selectedValue = obj;
  }

  registerOnChange(fn: any): void {
  this.propagateChange = fn;
  }

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

  setDisabledState(isDisabled: boolean): void {
  this.disabled = isDisabled;
  }
  }

This is my component template:

<select class="form-control"
  [disabled]="disabled"
  [(ngModel)]="selectedValue"
  (ngModelChange)="propagateChange($event)">
  <option value="">Select an option</option>
  <option *ngFor="let option of settingsService.getOption(name)" [value]="option.description">
  {{option.description}}
  </option>
  </select>

回答1:

SAMPLE PLUNKER

I see two options:

  1. Propagate the errors from component FormControl to <select> FormControl whenever the <select> FormControl value changes
  2. Propagate the validators from component FormControl to <select> FormControl

Below the following variables are available:

  • selectModel is the NgModel of the <select>
  • formControl is the FormControl of the component received as an argument

Option 1: propagate errors

  ngAfterViewInit(): void {
    this.selectModel.control.valueChanges.subscribe(() => {
      this.selectModel.control.setErrors(this.formControl.errors);
    });
  }

Option 2: propagate validators

  ngAfterViewInit(): void {
    this.selectModel.control.setValidators(this.formControl.validator);
    this.selectModel.control.setAsyncValidators(this.formControl.asyncValidator);
  }

The difference between the two is that propagating the errors means having already the errors, while the seconds option involves executing the validators a second time. Some of them, like async validators might be too costly to perform.

Propagating all properties?

There is no general solution to propagate all the properties. Various properties are set by various directives, or other means, thus having different lifecycle, which means that require particular handling. Current solution regards propagating validation errors and validators. There are many properties available up there.

Note that you might get different status changes from the FormControl instance by subscribing to FormControl.statusChanges(). This way you can get whether the the control is VALID, INVALID, DISABLED or PENDING (async validation is still running).

How validation works under the hood?

Under the hood the validators are applied using directives (check the source code). The directives have providers: [REQUIRED_VALIDATOR] which means that own hierarchical injector is used to register that validator instance. So depending on the attributes applied on the element, the directives will add validator instances on the injector associated to the target element.

Next, these validators are retrieved by NgModel and FormControlDirective.

Validators as well as value accessors are retrieved like:

  constructor(@Optional() @Host() parent: ControlContainer,
              @Optional() @Self() @Inject(NG_VALIDATORS) validators: Array<Validator|ValidatorFn>,
              @Optional() @Self() @Inject(NG_ASYNC_VALIDATORS) asyncValidators: Array<AsyncValidator|AsyncValidatorFn>,
              @Optional() @Self() @Inject(NG_VALUE_ACCESSOR)

and respectively:

  constructor(@Optional() @Self() @Inject(NG_VALIDATORS) validators: Array<Validator|ValidatorFn>,
              @Optional() @Self() @Inject(NG_ASYNC_VALIDATORS) asyncValidators: Array<AsyncValidator|AsyncValidatorFn>,
              @Optional() @Self() @Inject(NG_VALUE_ACCESSOR)
              valueAccessors: ControlValueAccessor[])

Note that @Self() is used, therefore own injector (of the element to which the directive is being applied) is used in order to obtain the dependencies.

NgModel and FormControlDirective have an instance of FormControl which actually update the value and execute the validators.

Therefore the main point to interact with is the FormControl instance.

Also all validators or value accessors are registered in the injector of the element to which they are applied. This means that the parent should not access that injector. So would be a bad practice to access from current component the injector provided by the <select>.

Sample code for Option 1 (easily replaceable by Option 2)

The following sample has two validators: one which is required and another which is a pattern which forces the option to match "option 3".

The PLUNKER

options.component.ts

import {AfterViewInit, Component, forwardRef, Input, OnInit, ViewChild} from '@angular/core';
import {ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR, NgModel} from '@angular/forms';
import {SettingsService} from '../settings.service';

const OPTIONS_VALUE_ACCESSOR: any = {
  multi: true,
  provide: NG_VALUE_ACCESSOR,
  useExisting: forwardRef(() => OptionsComponent)
};

@Component({
  providers: [OPTIONS_VALUE_ACCESSOR],
  selector: 'inf-select[name]',
  templateUrl: './options.component.html',
  styleUrls: ['./options.component.scss']
})
export class OptionsComponent implements ControlValueAccessor, OnInit, AfterViewInit {

  @ViewChild('selectModel') selectModel: NgModel;
  @Input() formControl: FormControl;

  @Input() name: string;
  @Input() disabled = false;

  private propagateChange: Function;
  private onTouched: Function;

  private settingsService: SettingsService;

  selectedValue: any;

  constructor(settingsService: SettingsService) {
    this.settingsService = settingsService;
  }

  ngOnInit(): void {
    if (!this.name) {
      throw new Error('Option name is required. eg.: <options [name]="myOption"></options>>');
    }
  }

  ngAfterViewInit(): void {
    this.selectModel.control.valueChanges.subscribe(() => {
      this.selectModel.control.setErrors(this.formControl.errors);
    });
  }

  writeValue(obj: any): void {
    this.selectedValue = obj;
  }

  registerOnChange(fn: any): void {
    this.propagateChange = fn;
  }

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

  setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }
}

options.component.html

<select #selectModel="ngModel"
        class="form-control"
        [disabled]="disabled"
        [(ngModel)]="selectedValue"
        (ngModelChange)="propagateChange($event)">
  <option value="">Select an option</option>
  <option *ngFor="let option of settingsService.getOption(name)" [value]="option.description">
    {{option.description}}
  </option>
</select>

options.component.scss

:host {
  display: inline-block;
  border: 5px solid transparent;

  &.ng-invalid {
    border-color: purple;
  }

  select {
    border: 5px solid transparent;

    &.ng-invalid {
      border-color: red;
    }
  }
}

Usage

Define the FormControl instance:

export class AppComponent implements OnInit {

  public control: FormControl;

  constructor() {
    this.control = new FormControl('', Validators.compose([Validators.pattern(/^option 3$/), Validators.required]));
  }
...

Bind the FormControl instance to the component:

<inf-select name="myName" [formControl]="control"></inf-select>

Dummy SettingsService

/**
 * TODO remove this class, added just to make injection work
 */
export class SettingsService {

  public getOption(name: string): [{ description: string }] {
    return [
      { description: 'option 1' },
      { description: 'option 2' },
      { description: 'option 3' },
      { description: 'option 4' },
      { description: 'option 5' },
    ];
  }
}