Техническое задание:
- нужно создать компонент «элемент формы для ввода СНИЛС»;
- компонент должен форматировать вводимые значения по маске;
- компонент должен выполнять валидацию вводимых данных;
- компонент должен работать как часть реактивной формы;
- незавершенная форма должна сохранять свое состояние между перезагрузками;
- при загрузке страницы, однажды отредактированная форма должна сразу показывать ошибки;
- используется 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/
Добавить комментарий