Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Can I access to formControl of my custom ControlValueAccessor in Angular 2+?

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.

enter image description here

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>
like image 428
SaWo Avatar asked May 26 '17 12:05

SaWo


People also ask

What does FormControl do in Angular?

What are form controls in Angular? In Angular, form controls are classes that can hold both the data values and the validation information of any form element. Every form input you have in a reactive form should be bound by a form control. These are the basic units that make up reactive forms.

What is control value accessor in Angular?

Control Value Accessor is an interface that provides us the power to leverage the Angular forms API and create a communication between Angular Form API and the DOM element. It provides us many facilities in angular like we can create custom controls or custom component with the help of control value accessor interface.


2 Answers

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' },
    ];
  }
}
like image 104
andreim Avatar answered Oct 11 '22 23:10

andreim


Here is what in my opinion is the cleanest solution to access FormControl in a ControlValueAccessor based component. Solution was based on what is mention here in Angular Material documentation.

// parent component template
<my-text-input formControlName="name"></my-text-input>
@Component({
  selector: 'my-text-input',
  template: '<input
    type="text"
    [value]="value"
  />',
})
export class MyComponent implements AfterViewInit, ControlValueAccessor  {

  // Here is missing standard stuff to implement ControlValueAccessor interface

  constructor(@Optional() @Self() public ngControl: NgControl) {
    if (ngControl != null) {
      // Setting the value accessor directly (instead of using
      // the providers) to avoid running into a circular import.
      ngControl.valueAccessor = this;
    }
  }

    ngAfterContentInit(): void {
       const control = this.ngControl && this.ngControl.control;
       if (control) {
          // FormControl should be available here
       }
    }
}
like image 25
Rui Marques Avatar answered Oct 12 '22 00:10

Rui Marques