Разнообразные формы в наших веб-приложениях нередко строятся из одинаковых кирпичиков-элементов. Компонентные фреймворки помогают нам избавиться от повторяемого кода, и сейчас я хочу рассмотреть один из таких подходов. Так, как это принято в Angular.
Прежде всего сам Angular Material
Потом нам нужно наложить маску и проверять сами СНИЛС согласно правилам расчета контрольной суммы.
Установим библиотеки:
Данные формы будут сохраняться в localStorage.
Можно сразу взять методы браузера для работы с LS, но в Ангуляре принято стараться писать универсальный код, а все внешние зависимости держать под контролем. Это так же упрощает тестирование.
Поэтому будет правильно когда класс получает все свои зависимости из DI контейнера. Вспоминая кота Матроскина — чтобы купить у инжектора что-нибудь ненужное, нужно сначала продать инжектору что-нибудь ненужное.
Создаем провайдер
window.provider.ts
Что тут происходит? Инжектор DI в Angular запоминает токены и отдает сущности, которые с ними связаны. Токен — это может быть объект InjectionToken, строка или класс. Тут создается новый InjectionToken root-уровня и связывается с фабрикой, которая возвращает браузерный
Теперь, когда у нас есть window, создадим простой сервис для работы с LocalStorage
storage.service.ts
StorageService забирает window из инжектора и предоставляет свои обертки для сохранения и чтения данных. Я не стал делать префикс конфигурируемым, дабы не перегружать статью описанием как создавать модули с конфигурацией.
Создаем несложный сервис для сохранения данных формы.
form-persist.service.ts
FormPersistService умеет регистрировать у себя формы по переданному строковому ключу. Регистрация означает, что данные формы будут при каждом изменении сохраняться в LS.
При регистрации так же возвращается извлеченное из LS значение, чтобы возможно было понять что форма уже сохранялась ранее.
Отмена регистрации (
Хочется описать функционал сохранения декларативно, а не заниматься этим каждый раз в коде компонента. Angular позволяет творить чудеса с помощью директив, и сейчас как раз тот случай.
Создаем директиву
form-persist.directive.ts
FormPersistDirective при наложении на форму вытаскивает из локального инжектора другую директиву — FormGroupDirective и берет оттуда объект реактивной формы для регистрации в FormPersistService.
Строковой ключ для регистрации приходится брать из шаблона, сама форма не имеет никакого присущего ей уникального идентификатора.
При сабмите формы регистрация должна отменяться. Для этого нужно слушать событие submit с помощь HostListener.
Так же директиву нужно доставлять в компоненты, где она сможет быть использована. Хорошей практикой является создание отдельных маленьких модулей для каждой переиспользуемой сущности.
form-persist.module.ts
Какие на него возлагаются задачи?
В первую очередь он должен валидировать данные.
Angular позволяет прикреплять к контролам форм свои валидаторы, и пора сделать такой свой. Для проверки СНИЛС я использую библиотеку внешнюю ru-validation-codes и валидатор будет совсем простым.
snils.validator.ts
Шаблон компонента состоит из обернутого поля инпута, классический вариант из библиотеки Angular Material.
С одним небольшим дополнением, на инпут будет наложена маска ввода, с помощью внешней библиотеки ngx-mask, от нее тут инпут-параметры mask — задает маску и dropSpecialCharacters — выключает удаление из значения спецсимволов маски.
Подробней в документации ngx-mask
Вот шаблон компонента
input-snils.component.html
Возникает вопрос, а что это за formControl | snilsErrors? Это кастомный пайп для отображения ошибок, сейчас мы его создадим.
snils-errors.pipe.ts
Пайп не является чистым, а значит будет выполняться при каждой детекции изменений.
Пайп принимает параметр типа ошибки и детектит ошибки трех типов:
И возвращает булевое значение — есть такая ошибка или нет.
А сейчас посмотрим на сам код компонента
input-snils.component.ts
Тут много всего и я не буду описывать как работать с 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
При использовании ControlValueAccessor есть следующий нюанс:
Реактивная форма имеет состояния touched и pristine (в дальнейшем просто «состояния»).
Так же их можно выставлять принудительно, но это никак не отразится на контроле внутри ControlValueAccessor, для нашего компонента это formControl.
А ошибки mat-error отрисовываются только когда текущий контрол touched. У нас есть требование, чтобы восстановленная форма сразу же отображала ошибки валидации, поэтому FormPersistDirective выполняет markAllAsTouched, если значение формы было прочитано из localStorage. Но ошибки mat-error отображены не будут, так как они находится внутри нашего ControlValueAccessor компонента, зависят от контрола formControl, а на этом независимом контроле состояние touched по прежнему false.
Нужен механизм прокидывания этих состояний. Для этого можно сделать свой аналог ControlValueAccessor, назовем его StateValueAccessor.
Для начала нужно создать токен и интерфейс.
state-value-accessor.token.ts
state-value-accessor.interface.ts
Интерфейс описывает требования, чтобы класс, имплементирующий его имел (опционально) два указанных метода. Эти методы реализованы в InputSnilsComponent и принудительно устанавливают эти состояние на внутреннем контроле formControl.
Затем понадобится директива, чтобы связать NgControl и наш компонент, реализующий StateValueAccessor. Нельзя точно определить момент, когда у формы меняются состояния, но мы знаем, что при любом изменении формы Angular помечает компонент как ожидающий цикла детекции изменений. У проверяемого компонента и его потомков выполняется lifecycle хук ngDoCheck, который и будет использовать наша директива.
Создаем директиву
form-statuses.directive.ts
FormStatusesDirective накладывается на все возможные контролы и проверяет наличие StateValueAccessor. Для этого у инжектора запрашивается опциональная зависимость по токену STATE_VALUE_ACCESSOR, который компонент, реализующий StateValueAccessor должен был запровайдить.
Если по токену ничего не найдено, то ничего не происходит, методы setSVATouched и setSVAPristine будут просто пустыми функциями.
Если же StateValueAccessor найден, то его методы setTouchedState и setPristineState будут вызваны при каждом обнаруженном изменении состояний.
Осталось снабдить директиву модулем для экспорта
form-statuses.module.ts
Теперь нужно создать саму форму. Чтобы не городить лишнего поместим ее на основную страницу AppComponent. Конечно в реальном приложении лучше делать под форму отдельный компонент.
Шаблон
app.component.html
На форме висит директива FormPersistDirective, Angular узнает об этом посредством селектора form[formPersist].
Шаблону нужно предоставить переменные, сделаем это
app.component.ts
Код компонента с формой вышел крайне простым и не содержит ничего лишнего.
Выглядит следующим образом:
Исходный код можно взять на GitHub
Демо на stackblitz
Код на stackblitz немного отличается, из-за того что версия typescript там еще не поддерживает элвис-оператор.
Кроме того, есть еще нюансы, не отраженные в статье, если кому-то будет нужно, я дополню.
Техническое задание:
- нужно создать компонент «элемент формы для ввода СНИЛС»;
- компонент должен форматировать вводимые значения по маске;
- компонент должен выполнять валидацию вводимых данных;
- компонент должен работать как часть реактивной формы;
- незавершенная форма должна сохранять свое состояние между перезагрузками;
- при загрузке страницы, однажды отредактированная форма должна сразу показывать ошибки;
- используется 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 там еще не поддерживает элвис-оператор.
Кроме того, есть еще нюансы, не отраженные в статье, если кому-то будет нужно, я дополню.
cccco
Статья понравилась. Всё просто, понятно, без лишних слов. Читать было интересно. Спасибо!
Xuxicheta Автор
Спасибо за фидбэк, это моя первая полностью самостоятельная статья по фронтенду на своих же наработках.