Angular: создание кастомного элемента формы и передача в него состояния формы

от автора

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

Подробней в документации jsdaddy.github.io/ngx-mask-page/main

Вот шаблон компонента
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 и на этом контроле состояние 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 там еще не поддерживает элвис-оператор.

ссылка на оригинал статьи https://habr.com/ru/post/491062/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *