Angular: creating a custom form element and passing form state to it

The various forms in our web applications are often built from the same brick elements. Component frameworks help us get rid of repeatable code, and now I want to consider one of these approaches. So, as is customary in Angular.

Technical task:


  • you need to create the component "form element for entering SNILS";
  • The component should format the entered values ​​by mask;
  • The component must validate the input.
  • the component should work as part of the reactive form;
  • an incomplete form must maintain its state between reboots;
  • when loading the page, once the edited form should immediately show errors;
  • used by Angular Material.

Creating a project and installing dependencies


Create a test project


ng new input-snils

Install third-party libraries


First of all, Angular Material itself

ng add @angular/material

Then we need to mask and check the SNILS themselves according to the rules for calculating the checksum .

Install the libraries:

npm install ngx-mask ru-validation-codes

Saving form data


Preparing a service for working with localStorage


Form data will be stored in localStorage.

You can immediately take the browser methods for working with LS, but in Angular it is customary to try to write universal code, and keep all external dependencies under control. It also simplifies testing.

Therefore, it will be correct when the class gets all its dependencies from the DI container. Remembering the cat Matroskin - in order to buy something unnecessary from the injector, you must first sell something unnecessary to the injector.

Create a window.provider.ts provider



import { InjectionToken } from '@angular/core';

export function getWindow() {
  return window;
}

export const WINDOW = new InjectionToken('Window', {
  providedIn: 'root',
  factory: getWindow,
});

What's going on here? Angular's DI Injector remembers tokens and gives away entities that are associated with them. Token - it can be an InjectionToken object, a string or a class. This creates a new root-level InjectionToken and communicates with the factory, which returns the browser window.

Now that we have a window, let's create a simple service for working with LocalStorage
storage.service.ts

@Injectable({
  providedIn: 'root'
})
export class StorageService {
  readonly prefix = 'snils-input__';

  constructor(
    @Inject(WINDOW) private window: Window,
  ) {}

  public set<T>(key: string, data: T): void {
    this.window.localStorage.setItem(this.prefix + key, JSON.stringify(data));
  }

  public get<T>(key: string): T {
    try {
      return JSON.parse(this.window.localStorage.getItem(this.prefix + key));
    } catch (e) { }
  }

  public remove(key: string): void {
    this.window.localStorage.removeItem(this.prefix + key);
  }
}

StorageService takes a window from an injector and provides its own wrappers for saving and reading data. I did not make the prefix configurable so as not to overload the article with a description of how to create modules with a configuration.

FormPersistModule


We create a simple service for saving form data.

form-persist.service.ts

@Injectable({
  providedIn: 'root'
})
export class FormPersistService {
  private subscriptions: Record<string, Subscription> = {};

  constructor(
    private storageService: StorageService,
  ) { }

  /**
   * @returns restored data if exists
   */
  public registerForm<T>(formName: string, form: AbstractControl): T {
    this.subscriptions[formName]?.unsubscribe();
    this.subscriptions[formName] = this.createFormSubscription(formName, form);

    return this.restoreData(formName, form);
  }

  public unregisterForm(formName: string): void {
    this.storageService.remove(formName);

    this.subscriptions[formName]?.unsubscribe();
    delete this.subscriptions[formName];
  }

  public restoreData<T>(formName: string, form: AbstractControl): T {
    const data = this.storageService.get(formName) as T;
    if (data) {
      form.patchValue(data, { emitEvent: false });
    }

    return data;
  }

  private createFormSubscription(formName: string, form: AbstractControl): Subscription {
    return form.valueChanges.pipe(
      debounceTime(500),
    )
      .subscribe(value => {
        this.storageService.set(formName, value);
      });
  }
}

FormPersistService is able to register forms with the passed string key. Registration means that the form data will be saved in LS with every change.
When registering, the value extracted from LS is also returned so that it is possible to understand that the form has already been saved earlier.

Unregister ( unregisterForm) terminates the save process and deletes the entry in LS.

I would like to describe the storage functionality declaratively, and not do it every time in the component code. Angular lets you do miracles with the help of directives, and right now that is the case.

Create the form-persist.directive.ts directive


@Directive({
  selector: 'form[formPersist]', // tslint:disable-line: directive-selector
})
export class FormPersistDirective implements OnInit {
  @Input() formPersist: string;

  constructor(
    private formPersistService: FormPersistService,
    @Self() private formGroup: FormGroupDirective,
  ) { }

  @HostListener('submit')
  onSubmit() {
    this.formPersistService.unregisterForm(this.formPersist);
  }

  ngOnInit() {
    const savedValue = this.formPersistService.registerForm(this.formPersist, this.formGroup.control);
    if (savedValue) {
      this.formGroup.control.markAllAsTouched();
    }
  }
}

When superimposed on the form, FormPersistDirective pulls out another directive from the local injector - FormGroupDirective and takes the reactive form object from there to register with FormPersistService.

The registration key must be taken from the template, the form itself does not have any unique identifier inherent in it.

When submitting a form, registration should be canceled. To do this, listen for the submit event using HostListener.

The directive also needs to be delivered to components where it can be used. It is good practice to create separate small modules for each reused entity.

form-persist.module.ts

@NgModule({
  declarations: [FormPersistDirective],
  exports: [FormPersistDirective]
})
export class FormPersistModule { }

Element of the SNILS form


What tasks are assigned to it?

First of all, it must validate the data.

snilsValidator


Angular allows you to attach your validators to form controls, and it's time to make your own. To check the SNILS, I use the external ru-validation-codes library and the validator will be quite simple.

snils.validator.ts

import { checkSnils } from 'ru-validation-codes';

export function snilsValidator(control: AbstractControl): ValidationErrors | null {
  if (control.value === ''  || control.value === null) {
    return null;
  }

  return checkSnils(control.value)
    ? null
    : { snils: 'error' };
}

Component InputSnilsComponent


The component template consists of a wrapped input field, a classic version from the Angular Material library.

With one small addition, an input mask will be superimposed on the input using the external library ngx-mask, from it here the input parameters mask - sets the mask and dropSpecialCharacters - turns off the removal of special mask characters from the value.

See the ngx-mask documentation for more details.

Here is the input-snils.component.html component template


<mat-form-field appearance="outline">
  <input
    matInput
    autocomplete="snils"
    [formControl]="formControl"
    [mask]="mask"
    [dropSpecialCharacters]="false"
    [placeholder]="placeholder"
    [readonly]="readonly"
    [required]="required"
    [tabIndex]="tabIndex"
  >
  <mat-error [hidden]="formControl | snilsErrors: 'required'">  </mat-error>
  <mat-error [hidden]="formControl | snilsErrors: 'format'">   </mat-error>
  <mat-error [hidden]="formControl | snilsErrors: 'snils'"> </mat-error>
</mat-form-field>

The question is, what is this formControl | snilsErrors? This is a custom pipe for displaying errors, now we will create it.

snils-errors.pipe.ts

type ErrorType = 'required' | 'format' | 'snils';

@Pipe({
  name: 'snilsErrors',
  pure: false,
})
export class SnilsErrorsPipe implements PipeTransform {

  transform(control: AbstractControl, errorrType: ErrorType): boolean {
    switch (errorrType) {
      case 'required': return !control.hasError('required');
      case 'format': return !control.hasError('Mask error');
      case 'snils': return control.hasError('Mask error') || !control.hasError('snils');
      default: return false;
    }
  }
}

The pipe is not clean, which means it will be executed every time changes are detected.

Pipe accepts an error type parameter and detects errors of three types:

  • “Required” - this error is from the Angular directive RequiredValidator built-in
  • "Snils" - this error is from our validator snilsValidator
  • "Mask error" - this error from the MaskDirective directive from the ngx-mask library

And it returns a boolean value - is there such an error or not.

Now, let's look at the input-snils.component.ts component code
itself

@Component({
  selector: 'app-input-snils',
  templateUrl: './input-snils.component.html',
  styleUrls: ['./input-snils.component.css'],
  encapsulation: ViewEncapsulation.None,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => InputSnilsComponent),
      multi: true
    },
    {
      provide: NG_VALIDATORS,
      useExisting: forwardRef(() => InputSnilsComponent),
      multi: true,
    },
    {
      provide: STATE_VALUE_ACCESSOR,
      useExisting: forwardRef(() => InputSnilsComponent),
    },
  ]
})
export class InputSnilsComponent implements OnInit, ControlValueAccessor, StateValueAccessor, OnDestroy {
  public mask = '000-000-000 00';
  public formControl = new FormControl('', [snilsValidator]);
  private sub = new Subscription();

  @Input() readonly: boolean;
  @Input() placeholder = '';
  @Input() tabIndex = 0;
  @Input() required: boolean;

  private onChange = (value: any) => { };
  private onTouched = () => { };
  registerOnChange = (fn: (value: any) => {}) => this.onChange = fn;
  registerOnTouched = (fn: () => {}) => this.onTouched = fn;


  ngOnInit() {
    this.sub = this.linkForm();
  }

  ngOnDestroy() {
    this.sub.unsubscribe();
  }

  private linkForm(): Subscription {
    return this.formControl.valueChanges.subscribe(value => {
      this.onTouched();
      this.onChange(value);
    });
  }

  writeValue(outsideValue: string): void {
    if (outsideValue) {
      this.onTouched();
    }
    this.formControl.setValue(outsideValue, { emitEvent: false });
  }

  setDisabledState(disabled: boolean) {
    disabled
      ? this.formControl.disable()
      : this.formControl.enable();
  }

  validate(): ValidationErrors | null {
    return this.formControl.errors;
  }

  setPristineState(pristine: boolean) {
    pristine
      ? this.formControl.markAsPristine()
      : this.formControl.markAsDirty();

    this.formControl.updateValueAndValidity({ emitEvent: false });
  }

  setTouchedState(touched: boolean) {
    touched
      ? this.formControl.markAsTouched()
      : this.formControl.markAsUntouched();

    this.formControl.updateValueAndValidity({ emitEvent: false });
  }
}

There are a lot of things and I will not describe how to work with ControlValueAccessor, you can read about this in the Angular documentation, or for example here tyapk.ru/blog/post/angular-custom-form-field-control

What needs explanation here?

First, we use the internal control of the form formControl, we attach to its changes to send the value change to the top, via the onChange and onTouched methods.
And vice versa, changes to the external form come to us through the writeValue and setDisabledState methods and are reflected in formControl.

Secondly, there is an unknown STATE_VALUE_ACCESSOR token, an unknown StateValueAccessor interface and a couple of extra setPristineState and setTouchedState methods. They will be explained later.

In the meantime, create a personal module for the component
input-snils.module.ts

@NgModule({
  declarations: [InputSnilsComponent, SnilsErrorsPipe],
  imports: [
    CommonModule,
    MatFormFieldModule,
    MatInputModule,
    NgxMaskModule.forChild(),
    ReactiveFormsModule,
  ],
  exports: [InputSnilsComponent],
})
export class InputSnilsModule { }

Passing statuses to an item


When using ControlValueAccessor, there is the following nuance: The

reactive form has touched and pristine states (hereinafter simply “states”).

  • pristine is initially true and changes to false when the control value is changed from the template
  • touched initially false and changes to true when control lost focus

They can also be set forcibly, but this will not affect the control inside the ControlValueAccessor, for our component it is formControl.

And mat-error errors are rendered only when the current control is touched. We have a requirement that the restored form immediately display validation errors, so FormPersistDirective executes markAllAsTouched if the form value has been read from localStorage. But mat-errors will not be displayed, since they are inside our ControlValueAccessor component, they depend on the formControl control, and on this independent control the touched state is still false.

We need a mechanism for throwing these states. To do this, you can make your own analogue of ControlValueAccessor, let's call it StateValueAccessor.

First you need to create a token and interface.

state-value-accessor.token.ts

export const STATE_VALUE_ACCESSOR = new InjectionToken<StateValueAccessor>('STATE_VALUE_ACCESSOR');


state-value-accessor.interface.ts
export interface StateValueAccessor {
  setTouchedState?(touched: boolean): void;
  setPristineState?(pristine: boolean): void;
}

The interface describes the requirements for the class implementing it to have (optionally) two specified methods. These methods are implemented in InputSnilsComponent and force these states on the internal control formControl.

Then you need a directive to bind NgControl and our component that implements StateValueAccessor. It’s impossible to pinpoint the moment when the state of a form changes, but we know that whenever a form changes, Angular marks the component as waiting for a change detection cycle. The tested component and its descendants execute the ngDoCheck lifecycle hook, which our directive will use.

FormStatusesDirective


Create the directive
form-statuses.directive.ts

const noop: (v?: boolean) => void = () => { };

@Directive({
  selector: '[formControlName],[ngModel],[formControl]' // tslint:disable-line: directive-selector
})
export class FormStatusesDirective implements DoCheck, OnInit {
  private setSVATouched = noop;
  private setSVAPristine = noop;

  constructor(
    @Self() private control: NgControl,
    @Self() @Optional()  @Inject(STATE_VALUE_ACCESSOR) private stateValueAccessor: StateValueAccessor,
  ) { }

  ngOnInit() {
    if (this.stateValueAccessor?.setTouchedState) {
      this.setSVATouched = wrapIfChanges(touched => this.stateValueAccessor.setTouchedState(touched));
    }

    if (this.stateValueAccessor?.setPristineState) {
      this.setSVAPristine = wrapIfChanges(pristine => this.stateValueAccessor.setPristineState(pristine));
    }
  }

  ngDoCheck() {
    this.setSVAPristine(this.control.pristine);
    this.setSVATouched(this.control.touched);
  }
}

FormStatusesDirective is superimposed on all possible controls and checks for the presence of StateValueAccessor. To do this, an optional dependency on the STATE_VALUE_ACCESSOR token is requested from the injector, which the component implementing StateValueAccessor should have checked.

If nothing is found by the token, then nothing happens, the setSVATouched and setSVAPristine methods will be just empty functions.

If StateValueAccessor is found, then its setTouchedState and setPristineState methods will be called for every detected state change.

It remains to provide the directive with a module for exporting
form-statuses.module.ts

@NgModule({
  declarations: [FormStatusesDirective],
  exports: [FormStatusesDirective]
})
export class FormStatusesModule { }

Main page


Now you need to create the form itself. In order not to make a fuss, put it on the main page of the AppComponent. Of course, in a real application, it is better to make a separate component for the form. App.component.html
template


<section class="form-wrapper">
  <form
    class="form"
    [formGroup]="form"
    formPersist="inputSnils"
  >
    <app-input-snils
      class="input-snils"
      formControlName="snils"
      [required]="true"
    ></app-input-snils>

    <button
      class="ready-button"
      mat-raised-button
      [disabled]="form.invalid"
      type="submit"
    >
      Submit
    </button>
  </form>
</section>

The FormPersistDirective directive hangs on the form, Angular learns about it through the form [formPersist] selector.

The template needs to be provided with variables, let's do it
app.component.ts

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  public form = new FormGroup({
    snils: new FormControl('', [Validators.required])
  });
}

The component code with the form came out extremely simple and does not contain anything superfluous.

It looks like this:


The source code can be taken on GitHub
The demo on stackblitz
The code on stackblitz is slightly different, due to the fact that the typescript version there does not yet support the elvis operator.
In addition, there are still nuances that are not reflected in the article, if someone needs it, I will supplement it.

All Articles