Разнообразные формы в наших веб-приложениях нередко строятся из одинаковых кирпичиков-элементов. Компонентные фреймворки помогают нам избавиться от повторяемого кода, и сейчас я хочу рассмотреть один из таких подходов. Так, как это принято в Angular.

Техническое задание:


  • нужно создать компонент «элемент формы для ввода СНИЛС»;
  • компонент должен форматировать вводимые значения по маске;
  • компонент должен выполнять валидацию вводимых данных;
  • компонент должен работать как часть реактивной формы;
  • незавершенная форма должна сохранять свое состояние между перезагрузками;
  • при загрузке страницы, однажды отредактированная форма должна сразу показывать ошибки;
  • используется Angular Material.

Создание проекта и установка зависимостей


Создадим тестовый проект


ng new input-snils

Установим сторонние библиотеки


Прежде всего сам Angular Material

ng add @angular/material

Потом нам нужно наложить маску и проверять сами СНИЛС согласно правилам расчета контрольной суммы.

Установим библиотеки:

npm install ngx-mask ru-validation-codes

Сохранение данных формы


Подготовка сервиса для работы с localStorage


Данные формы будут сохраняться в localStorage.

Можно сразу взять методы браузера для работы с LS, но в Ангуляре принято стараться писать универсальный код, а все внешние зависимости держать под контролем. Это так же упрощает тестирование.

Поэтому будет правильно когда класс получает все свои зависимости из DI контейнера. Вспоминая кота Матроскина — чтобы купить у инжектора что-нибудь ненужное, нужно сначала продать инжектору что-нибудь ненужное.

Создаем провайдер

window.provider.ts

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

export function getWindow() {
  return window;
}

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

Что тут происходит? Инжектор DI в Angular запоминает токены и отдает сущности, которые с ними связаны. Токен — это может быть объект InjectionToken, строка или класс. Тут создается новый InjectionToken root-уровня и связывается с фабрикой, которая возвращает браузерный window.

Теперь, когда у нас есть window, создадим простой сервис для работы с 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 забирает window из инжектора и предоставляет свои обертки для сохранения и чтения данных. Я не стал делать префикс конфигурируемым, дабы не перегружать статью описанием как создавать модули с конфигурацией.

FormPersistModule


Создаем несложный сервис для сохранения данных формы.

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 умеет регистрировать у себя формы по переданному строковому ключу. Регистрация означает, что данные формы будут при каждом изменении сохраняться в LS.
При регистрации так же возвращается извлеченное из LS значение, чтобы возможно было понять что форма уже сохранялась ранее.

Отмена регистрации (unregisterForm) прекращает процесс сохранения и удаляет запись в LS.

Хочется описать функционал сохранения декларативно, а не заниматься этим каждый раз в коде компонента. Angular позволяет творить чудеса с помощью директив, и сейчас как раз тот случай.

Создаем директиву
form-persist.directive.ts

@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();
    }
  }
}

FormPersistDirective при наложении на форму вытаскивает из локального инжектора другую директиву — FormGroupDirective и берет оттуда объект реактивной формы для регистрации в FormPersistService.

Строковой ключ для регистрации приходится брать из шаблона, сама форма не имеет никакого присущего ей уникального идентификатора.

При сабмите формы регистрация должна отменяться. Для этого нужно слушать событие submit с помощь HostListener.

Так же директиву нужно доставлять в компоненты, где она сможет быть использована. Хорошей практикой является создание отдельных маленьких модулей для каждой переиспользуемой сущности.

form-persist.module.ts

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

Элемент формы «СНИЛС»


Какие на него возлагаются задачи?

В первую очередь он должен валидировать данные.

snilsValidator


Angular позволяет прикреплять к контролам форм свои валидаторы, и пора сделать такой свой. Для проверки СНИЛС я использую библиотеку внешнюю ru-validation-codes и валидатор будет совсем простым.

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' };
}

Компонет InputSnilsComponent


Шаблон компонента состоит из обернутого поля инпута, классический вариант из библиотеки Angular Material.

С одним небольшим дополнением, на инпут будет наложена маска ввода, с помощью внешней библиотеки ngx-mask, от нее тут инпут-параметры mask — задает маску и dropSpecialCharacters — выключает удаление из значения спецсимволов маски.

Подробней в документации ngx-mask

Вот шаблон компонента
input-snils.component.html

<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>

Возникает вопрос, а что это за formControl | snilsErrors? Это кастомный пайп для отображения ошибок, сейчас мы его создадим.

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;
    }
  }
}

Пайп не является чистым, а значит будет выполняться при каждой детекции изменений.

Пайп принимает параметр типа ошибки и детектит ошибки трех типов:

  • «required» — эта ошибка от встроенной в Angular директивы RequiredValidator
  • «snils» — эта ошибка от нашего валидатора snilsValidator
  • «Mask error» — эта ошибка от директивы MaskDirective из библиотеки ngx-mask

И возвращает булевое значение — есть такая ошибка или нет.

А сейчас посмотрим на сам код компонента
input-snils.component.ts

@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 });
  }
}

Тут много всего и я не буду описывать как работать с ControlValueAccessor, об этом можно прочесть в документации Angular, или например тут tyapk.ru/blog/post/angular-custom-form-field-control

Что тут требует объяснения?

Во-первых мы используем внутренний контрол формы formControl, привязываемся к его изменениям, чтобы отправить изменение значения наверх, через методы onChange и onTouched.
И в обратную сторону, изменения внешней формы приходят к нам через методы writeValue и setDisabledState и отражаются в formControl.

Во-вторых, тут есть неизвестный токен STATE_VALUE_ACCESSOR, неизвестный интерфейс StateValueAccessor и пара лишних методов setPristineState и setTouchedState. Они будут разъяснены далее.

А пока создадим для компонента его персональный модуль
input-snils.module.ts

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

Передача статусов в элемент


При использовании ControlValueAccessor есть следующий нюанс:

Реактивная форма имеет состояния touched и pristine (в дальнейшем просто «состояния»).

  • pristine изначально true и меняется на false когда значение контрола изменено из шаблона
  • touched изначально false и меняется на true когда контрол потерял фокус

Так же их можно выставлять принудительно, но это никак не отразится на контроле внутри ControlValueAccessor, для нашего компонента это formControl.

А ошибки mat-error отрисовываются только когда текущий контрол touched. У нас есть требование, чтобы восстановленная форма сразу же отображала ошибки валидации, поэтому FormPersistDirective выполняет markAllAsTouched, если значение формы было прочитано из localStorage. Но ошибки mat-error отображены не будут, так как они находится внутри нашего ControlValueAccessor компонента, зависят от контрола formControl, а на этом независимом контроле состояние touched по прежнему false.

Нужен механизм прокидывания этих состояний. Для этого можно сделать свой аналог ControlValueAccessor, назовем его StateValueAccessor.

Для начала нужно создать токен и интерфейс.

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;
}

Интерфейс описывает требования, чтобы класс, имплементирующий его имел (опционально) два указанных метода. Эти методы реализованы в InputSnilsComponent и принудительно устанавливают эти состояние на внутреннем контроле formControl.

Затем понадобится директива, чтобы связать NgControl и наш компонент, реализующий StateValueAccessor. Нельзя точно определить момент, когда у формы меняются состояния, но мы знаем, что при любом изменении формы Angular помечает компонент как ожидающий цикла детекции изменений. У проверяемого компонента и его потомков выполняется lifecycle хук ngDoCheck, который и будет использовать наша директива.

FormStatusesDirective


Создаем директиву
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 накладывается на все возможные контролы и проверяет наличие StateValueAccessor. Для этого у инжектора запрашивается опциональная зависимость по токену STATE_VALUE_ACCESSOR, который компонент, реализующий StateValueAccessor должен был запровайдить.

Если по токену ничего не найдено, то ничего не происходит, методы setSVATouched и setSVAPristine будут просто пустыми функциями.

Если же StateValueAccessor найден, то его методы setTouchedState и setPristineState будут вызваны при каждом обнаруженном изменении состояний.

Осталось снабдить директиву модулем для экспорта
form-statuses.module.ts

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

Основная страница


Теперь нужно создать саму форму. Чтобы не городить лишнего поместим ее на основную страницу AppComponent. Конечно в реальном приложении лучше делать под форму отдельный компонент.
Шаблон
app.component.html

<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>

На форме висит директива FormPersistDirective, Angular узнает об этом посредством селектора form[formPersist].

Шаблону нужно предоставить переменные, сделаем это
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])
  });
}

Код компонента с формой вышел крайне простым и не содержит ничего лишнего.

Выглядит следующим образом:


Исходный код можно взять на GitHub
Демо на stackblitz
Код на stackblitz немного отличается, из-за того что версия typescript там еще не поддерживает элвис-оператор.
Кроме того, есть еще нюансы, не отраженные в статье, если кому-то будет нужно, я дополню.