Search code examples
angularangular-materialangular-forms

Binding FormControl validators to a custom form Material Select component


I have this stackblitz set up by way of an example.

I have a standard input form field and a custom field that shows a select bound to an array.

<form [formGroup]="formGroup">
    <mat-form-field class="field">
      <mat-label>City</mat-label>
      <input matInput placeholder="City" formControlName="address1" />
    </mat-form-field>

    <app-dataset-select label="Country" [items]="countries" formControlName="countryId"></app-dataset-select>

</form>

The whole thing is wrapped by a form with validation:

 this.formGroup = new FormGroup({
    address1: new FormControl(model.address1, Validators.required),
    countryId: new FormControl(model.countryId, Validators.required)
  });

When I click SAVE I expect both fields to visibly show validation - the FormGroup itself says we do.

But the Country control does not get the ng-invalid state (and thus no redness) and I'm not sure why - although its something to do with angular's reactive forms kung-fu black magic...

enter image description here


Solution

  • Big thanks to @Eliseo but that solution was not working for me on my existing code (different way of binding, Angular 8?) and I was getting even more frustrated - ngControl.control was always undefined..

    The solution does not require a custom ErrorStateMatcher apparently, but the answer is to ensure the mat-select is bound to the FormControl in the FormGroup which is fiddly due to life-cycle events, but effectively:

    export class DatasetSelectComponent extends AbstractFormFieldComponent {
      @Input() label!: string;
      @Input() items!: [{id: number, label: string}];
    }
    
    export abstract class AbstractFormFieldComponent implements  ControlValueAccessor {
    
      // tslint:disable-next-line:variable-name
      _formControl = new FormControl(); 
      onChange = (value: any) => {};
    
     constructor(@Self() @Optional() public ngControl: NgControl) {
        if(this.ngControl) {
          this.ngControl.valueAccessor = this;
        }
      }
    
      ngAfterViewInit(): void {
        if (this.ngControl) {
          /**
           * get a handle on the FormControl that was created in the last Reactive FormGroup in the component injection hierarchy
           * so that it can be bound to the input in our Custom Component
           * this ensures input value binding to model + explicit validation is bound
           * e.g. new FormGroup({ titleId: new FormControl(personalDetails.titleId, Validators.required) } =>
           *    <input [formControl]="this.formControl"
           * otherwise you will have to do that manually for evey single control on every single form
           * which is obviously a lot of repeating yourself
           */
    
          of(this.ngControl.control)
            .pipe(
              skipWhile(fc => !fc),
              take(1)
            )
            .subscribe(fc => {
              this.formControl = fc as FormControl;
              console.log(
                'Custom FormControl (AbstractFormFieldComponent): Binding to Reactive Form',
                this.ngControl,
                this.ngControl.control
              );
            });
        }
    
      get formControl() :FormControl|RequiredFormControl {
        return this._formControl;
      }
      set formControl(forControl:FormControl|RequiredFormControl)  {
        this._formControl = forControl;
      }
    
      registerOnChange(fn: (value: any) => void): void {
        this.onChange = fn;
      }
    
      registerOnTouched(fn: (value: any) => void): void {}
    
      writeValue(value: any): void {
        if(this.formControl) this.formControl.setValue(value, { emitEvent: false });
      }
    
    
    }
    

    Note the removal of the component injection of NG_VALUE_ACCESSOR (replaced by the workings in the constructor), which prevents a cyclical dependency compile-time error:

    providers: [
        {
          provide: NG_VALUE_ACCESSOR,
          multi: true,
          useExisting: forwardRef(() => CustomSelectComponent),
        }
      ]
    

    And a snippet from the template:

      <mat-select [formControl]="formControl" [required]="formControl.required">
        <mat-option *ngFor="let item of items" [value]="item.id">
          {{ item.label }}
        </mat-option>
      </mat-select>
    

    Updated blitz

    enter image description here