33

I'm trying to create a custom form control in Angular (v5). The custom control is essentially a wrapper around an Angular Material component, but with some extra stuff going on.

I've read various tutorials on implementing ControlValueAccessor, but I can't find anything that accounts for writing a component to wrap an existing component.

Ideally, I want a custom component that displays the Angular Material component (with some extra bindings and stuff going on), but to be able to pass in validation from the parent form (e.g. required) and have the Angular Material components handle that.

Example:

Outer component, containing a form and using custom component

<form [formGroup]="myForm">
    <div formArrayName="things">
        <div *ngFor="let thing of things; let i = index;">
            <app-my-custom-control [formControlName]="i"></app-my-custom-control>
        </div>
    </div>
</form>

Custom component template

Essentially my custom form component just wraps an Angular Material drop-down with autocomplete. I could do this without creating a custom component, but it seems to make sense to do it this way as all the code for handling filtering etc. can live within that component class, rather than being in the container class (which doesn't need to care about the implementation of this).

<mat-form-field>
  <input matInput placeholder="Thing" aria-label="Thing" [matAutocomplete]="thingInput">
  <mat-autocomplete #thingInput="matAutocomplete">
    <mat-option *ngFor="let option of filteredOptions | async" [value]="option">
      {{ option }}
    </mat-option>
  </mat-autocomplete>
</mat-form-field>

So, on the input changing, that value should be used as the form value.

Things I've tried

I've tried a few ways of doing this, all with their own pitfalls:

Simple event binding

Bind to keyup and blur events on the input, and then notify the parent of the change (i.e. call the function that Angular passes into registerOnChange as part of implementing ControlValueAccessor).

That sort of works, but on selecting a value from the dropdown it seems the change events don't fire and you end up in an inconsistent state.

It also doesn't account for validation (e.g. if it's "required", when a value isn;t set the form control will correctly be invalid, but the Angular Material component won't show as such).

Nested form

This is a bit closer. I've created a new form within the custom component class, which has a single control. In the component template, I pass in that form control to the Angular Material component. In the class, I subscribe to valueChanges of that and then propagate the changes back to the parent (via the function passed into registerOnChange).

This sort of works, but feels messy and like there should be a better way.

It also means that any validation applied to my custom form control (by the container component) is ignored, as I've created a new "inner form" that lacks the original validation.

Don't use ControlValueAccessor at all, and instead just pass in the form

As the title says... I tried not doing this the "proper" way, and instead added a binding to the parent form. I then create a form control within the custom component as part of that parent form.

This works for handling value updates, and to an extent validation (but it has to be created as part of the component, not the parent form), but this just feels wrong.

Summary

What's the proper way of handling this? It feels like I'm just stumbling through different anti-patterns, but I can't find anything in the docs to suggest that this is even supported.

Tom Seldon
  • 510
  • 1
  • 6
  • 10
  • 2
    Read this [Never again be confused when implementing ControlValueAccessor in Angular forms](https://blog.angularindepth.com/never-again-be-confused-when-implementing-controlvalueaccessor-in-angular-forms-93b9eee9ee83) – Max Koretskyi Nov 19 '17 at 18:17
  • 5
    Hi, I had a read through that article and whilst it was a good read, I'm not sure it's that relevant here. That talks about how to wrap a third-party non-Angular component, whilst I'm trying to wrap an Angular component that itself already implements ControlValueAccessor. (It's a wrapper around Angular Material). So wrapping that, whilst also passing through any validation handling to the inner Angular Material component. – Tom Seldon Nov 19 '17 at 21:16
  • you can have several layers of wrappin: `Input <- customControl1 <- customControl2` where `customControl1` and `customControl2` implements `ControlValueAccessors`. What exactly do you need with validation? – Max Koretskyi Nov 20 '17 at 06:13
  • 1
    If I were to directly just use an Angular Material component in my form and attach a form control to it, then it handles the UI side of the validation (i.e. it shows as red, etc. when invalid). I'd like to wrap the Angular Material component, as there's some other stuff going on that lends itself well to being encapsulated like that, but still have the Angular Material component show as valid/invalid as appropriate. So: `Angular Material <- Custom control <- Wrapper component (containing the form)` – Tom Seldon Nov 20 '17 at 08:33
  • I've ended up dropping all of the ControlValueAccessor stuff, and instead just pass in the FormGroup and FormControl from the container component to the custom control component, and pass the form control on to the Angular Material component. This feels wrong, but it just works. – Tom Seldon Nov 20 '17 at 08:54
  • 2
    Tom would you be able to add your solution as an answer please? I am struggling with very similar issues. – Phil Degenhardt Jan 06 '18 at 21:58
  • Have you tried just inheriting your component from SelectControlValueAccessor? I started to do that myself, and it looked like it was working fine until I realized, that in my case, my component should just use a `select` element with an attribute selector. That latter option wouldn't work here, but maybe inheritance would. – pbristow Jul 10 '18 at 17:54

5 Answers5

17

Edit:

I've added a helper for doing just this an angular utilities library I've started: s-ng-utils. Using that you can extend WrappedFormControlSuperclass and write:

@Component({
  selector: 'my-wrapper',
  template: '<input [formControl]="formControl">',
  providers: [provideValueAccessor(MyWrapper)],
})
export class MyWrapper extends WrappedFormControlSuperclass<string> {
  // ...
}

See some more documentation here.


One solution is to get the @ViewChild() corresponding to the inner form components ControlValueAccessor, and delegating to it in your own component. For example:

@Component({
  selector: 'my-wrapper',
  template: '<input ngDefaultControl>',
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => NumberInputComponent),
      multi: true,
    },
  ],
})
export class MyWrapper implements ControlValueAccessor {
  @ViewChild(DefaultValueAccessor) private valueAccessor: DefaultValueAccessor;

  writeValue(obj: any) {
    this.valueAccessor.writeValue(obj);
  }

  registerOnChange(fn: any) {
    this.valueAccessor.registerOnChange(fn);
  }

  registerOnTouched(fn: any) {
    this.valueAccessor.registerOnTouched(fn);
  }

  setDisabledState(isDisabled: boolean) {
    this.valueAccessor.setDisabledState(isDisabled);
  }
}

The ngDefaultControl in the template above is to manually trigger angular to attach its normal DefaultValueAccessor to the input. This happens automatically if you use <input ngModel>, but we don't want the ngModel here, just the value accessor. You'll need to change DefaultValueAccessor above to whatever the value accessor is for the material dropdown - I'm not familiar with Material myself.

Eric Simonton
  • 5,702
  • 2
  • 37
  • 54
  • This sounds ideal, but the OP's got a `select` on the page. The DefaultValueAccessor has a selector for [ngDefaultControl] but the SelectControlValueAccessor has no similar selector. Unfortunately, I can't find any way to make this solution work given the circumstances (I'm doing almost exactly the same thing as the OP, myself). – pbristow Jul 10 '18 at 14:35
  • 3
    I believe this should be no problem with my updated answer, using [`WrappedFormControlSuperclass`](https://simontonsoftware.github.io/s-ng-utils/typedoc/classes/wrappedformcontrolsuperclass.html) from [`s-ng-utils`](https://github.com/simontonsoftware/s-ng-utils). – Eric Simonton Sep 06 '18 at 01:44
14

I'm a bit late to the party but here is what I did with wrapping a component which might accept formControlName, formControl, or ngModel

@Component({
  selector: 'app-input',
  template: '<input [formControl]="control">',
  styleUrls: ['./app-input.component.scss']
})
export class AppInputComponent implements OnInit, ControlValueAccessor {
  constructor(@Optional() @Self() public ngControl: NgControl) {
    if (this.ngControl != null) {
      // Setting the value accessor directly (instead of using the providers) to avoid running into a circular import.
      this.ngControl.valueAccessor = this;
    }
  }

  control: FormControl;

  // These are just to make Angular happy. Not needed since the control is passed to the child input
  writeValue(obj: any): void { }
  registerOnChange(fn: (_: any) => void): void { }
  registerOnTouched(fn: any): void { }

  ngOnInit() {
    if (this.ngControl instanceof FormControlName) {
      const formGroupDirective = this.ngControl.formDirective as FormGroupDirective;
      if (formGroupDirective) {
        this.control = formGroupDirective.form.controls[this.ngControl.name] as FormControl;
      }
    } else if (this.ngControl instanceof FormControlDirective) {
      this.control = this.ngControl.control;
    } else if (this.ngControl instanceof NgModel) {
      this.control = this.ngControl.control;
      this.control.valueChanges.subscribe(x => this.ngControl.viewToModelUpdate(this.control.value));
    } else if (!this.ngControl) {
      this.control = new FormControl();
    }
  }
}

Obviously, don't forget to unsubscribe from this.control.valueChanges

Maxim Balaganskiy
  • 1,524
  • 13
  • 25
6

I have actually been wrapping my head around this for a while and I figured out a good solution that is very similar (or the same) as Eric's. The thing he forgot to account for, is that you can't use the @ViewChild valueAccessor until the view has actually loaded (See @ViewChild docs)

Here is the solution: (I am giving you my example which is wrapping a core angular select directive with NgModel, since you are using a custom formControl, you will need to target that formControl's valueAccessor class)

@Component({
  selector: 'my-country-select',
  templateUrl: './country-select.component.html',
  styleUrls: ['./country-select.component.scss'],
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting:  CountrySelectComponent,
    multi: true
  }]
})
export class CountrySelectComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges {

  @ViewChild(SelectControlValueAccessor) private valueAccessor: SelectControlValueAccessor;

  private country: number;
  private formControlChanged: any;
  private formControlTouched: any;

  public ngAfterViewInit(): void {
    this.valueAccessor.registerOnChange(this.formControlChanged);
    this.valueAccessor.registerOnTouched(this.formControlTouched);
  }

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

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

  public writeValue(newCountryId: number): void {
    this.country = newCountryId;
  }

  public setDisabledState(isDisabled: boolean): void {
    this.valueAccessor.setDisabledState(isDisabled);
  }
}
Max101
  • 593
  • 1
  • 5
  • 18
0

Based on @Maxim Balaganskiy's answer, here is a base component you can use in ng>14.

You can just extend this component to expose a formControl into your child wrapped input:

Example:

@Component({
  imports: [ReactiveFormsModule],
  selector: 'med-input',
  standalone: true,
  template: '<input #testInput [formControl]="formControl" >',
})
export class TestInputComponent extends BaseFormControlComponent<string> {}

base-form-control.component.ts

import { Component, inject, OnInit } from '@angular/core';
import { ControlValueAccessor, FormControl, FormControlDirective, FormControlName, NgControl, NgModel } from '@angular/forms';

@Component({
  template: '',
})
export class BaseFormControlComponent<T> implements ControlValueAccessor, OnInit, OnDestroy {
  public formControl: FormControl<T>;
  private onDestroy$ = new Subject<void>();
  private ngControl = inject(NgControl, { optional: true, self: true });

  constructor() {
    if (!!this.ngControl) {
      this.ngControl.valueAccessor = this;
    }
  }

  // These are just to make Angular happy. Not needed since the control is passed to the child input
  writeValue(obj: any): void {}

  registerOnChange(fn: (_: any) => void): void {}

  registerOnTouched(fn: any): void {}

  ngOnDestroy(): void {
    this.onDestroy$.next();
    this.onDestroy$.complete();
  }

  ngOnInit(): void {
    this.formControl = this.buildFormControl();
  }

  private buildFormControl() {
    if (this.ngControl instanceof FormControlDirective) {
      return this.ngControl.control;
    }

    if (this.ngControl instanceof FormControlName) {
      return this.ngControl.formDirective.form.controls[this.ngControl.name];
    }

    if (this.ngControl instanceof NgModel) {
      const control = this.ngControl.control;
      control.valueChanges.pipe(takeUntil(this.onDestroy$)).subscribe((val) => this.ngControl.viewToModelUpdate(control.value));
      return control;
    }

    return new FormControl<T>(null);
  }
}

Bonus:

base-form-control.component.spec.ts

import { BaseFormControlComponent } from './base-form-control.component';
import { Component } from '@angular/core';
import { MockBuilder, MockRender, ngMocks } from 'ng-mocks';
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';

@Component({
  imports: [ReactiveFormsModule],
  selector: 'med-input',
  standalone: true,
  template: '<input #testInput [formControl]="formControl" >',
})
export class TestInputComponent extends BaseFormControlComponent<string> {}

describe('BaseFormControlComponentComponent', () => {
  beforeEach(() => MockBuilder(TestInputComponent).keep(ReactiveFormsModule).keep(FormsModule));

  it('should create', () => {
const fixture = MockRender(TestInputComponent);
fixture.detectChanges();
expect(fixture.componentInstance).toBeTruthy();
  });

  it(`should set the input's value from ngModel`, async () => {
const fixture = MockRender(`<med-input [ngModel]='foo'></med-input>`, { foo: 'bar' });
await fixture.whenStable();
const input = ngMocks.find('input');
expect(input.nativeElement.value).toContain('bar');
  });

  it(`should set the input's value from a formController`, async () => {
const fixture = MockRender(`<med-input [formControl]='formControl'></med-input>`, { formControl: new FormControl('bar') });
await fixture.whenStable();
const input = ngMocks.find('input');
expect(input.nativeElement.value).toContain('bar');
  });

  it('should get the input', async () => {
const fixture = MockRender(`<form [formGroup]='formGroup' ><med-input formControlName='foo'></med-input></form>`, {
  formGroup: new FormGroup({ foo: new FormControl('bar') }),
});
fixture.detectChanges();

await fixture.whenStable();
const input = ngMocks.find('input');
expect(input.nativeElement.value).toContain('bar');
  });

  it(`should disable the input value from ngModel`, async () => {
const fixture = MockRender(`<med-input [disabled]='true' [ngModel]='foo'></med-input>`, { foo: 'bar' });
await fixture.whenStable();
const input = ngMocks.find('input');
expect(input.nativeElement.disabled).toBeTruthy();
  });
});
Flavien Volken
  • 19,196
  • 12
  • 100
  • 133
-3

NgForm is providing an easy way to manage your forms without injecting any data in a HTML form. Input data must be injected at the component level not in a classic html tag.

<form #myForm="ngForm" (ngSubmit)="onSubmit(myForm)>...</form>

Other way is to create a form component where all the data model is binded using ngModel ;)

andrea06590
  • 1,259
  • 3
  • 10
  • 22