Введение

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

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

В свое время прочитав официальный гайд по реактивным формам я был удивлен простотой и элегантностью такого подхода к работе с формами. Но когда дело дошло до реального приложения я понял что гайд упускает одну очень маленькую, но не менее важную вещь - отображение ошибок.

На официальной документации я наткнулся на ещё один гайд, который покрывал данную тему, но когда начал читать его увидел следующее:

<div *ngIf="name.invalid && (name.dirty || name.touched)"
    class="alert">

  <div *ngIf="name.errors?.['required']">
    Name is required.
  </div>
  <div *ngIf="name.errors?.['minlength']">
    Name must be at least 4 characters long.
  </div>
  <div *ngIf="name.errors?.['forbiddenName']">
    Name cannot be Bob.
  </div>

</div>

Вам тоже кажется что такой подход к отображению ошибок может слегка затруднить общее понимание происходящего в шаблоне? Именно так я подумал и решил посмотреть какие же решения предлагают люди. Прочитав с десяток одинаковых статей и насмотревшись на горы копипасты вида:

<div *ngIf="form.get('control')?.hasError('required') && ...">
  This field is required
</div>

Я понял что наткнулся на очередную аномалию. Отображение ошибок формы это тривиальная задача, которая в принципе не должна вызывать трудностей, но тогда почему все примеры что я находил мягко говоря стыдно отправить в продакшн?

Ответ на данный вопрос до сих пор мне неизвестен, но вот способ как сделать нормальное отображение ошибок я все-таки нашел и именно о нем пойдет речь в данной статье.

Дисклеймер: данная статья предполагает что читатель знаком с такими темами как ReactiveForms, RxJS и Directives.

Описание решения

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

В финальном примере кода будет реализована локализация ошибок чтобы максимально приблизить пример к ситуации с которой столкнулся я. Уделять особое внимание локализации в статье не буду, скажу лишь что она реализована посредством библиотеки ngx-translate, а практическую инструкцию по внедрению и использованию можно найти в этой статье.

Создание директивы

Не будем медлить и создадим нашу директиву, назовем ее ControlError, а выглядеть будет она следующим образом:

@Directive({
    selector: '[formControl], [formControlName]'
})
export class ControlErrorDirective {
}

В данном фрагменте кода нас больше всего интересует selector директивы - он означает что данная директива будет создана на любом элементе, где присутствуют атрибуты formControl или formControlName, а это именно то что нам и нужно.

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

@Directive({
selector: 'showError[formControl], showError[formControlName]'
})

Итак, у нас есть директива которая будет сидеть на элементе. Теперь давайте реализуем отслеживание изменений значения контрола. Для этого мы воспользуется DI и просто попросим в конструкторе NgControl пометив параметр декоратором @Self.

Данный декоратор говорит ангуляру искать зависимость (в нашем случае NgControl) только в инжекторе компонента и не подниматься выше по иерархии.

Далее можем имплементировать OnInit и OnDestroy и спокойно подписываться на изменения значений контрола:

@Directive({
    selector: '[formControl], [formControlName]'
})
export class ControlErrorDirective implements OnInit, OnDestroy {

    private unsubscribe = new Subject();

    constructor(@Self() private control: NgControl){
    }

    public ngOnInit(): void {
        this.control.valueChanges.pipe(
          takeUntil(this.unsubscribe)
        ).subscribe(value => console.log(`Value changed: ${value}`));
    }

    public ngOnDestroy(): void {
        this.unsubscribe.next();
        this.unsubscribe.complete();
    }
}

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

Чтобы реагировать и на сабмиты форм нам таким же образом нужно получить и саму форму. Поскольку на самом теге form с реактивнымы формами у нас всегда будет висеть привязка к formGroup, то мы можем спокойно получить его через DI.

constructor(
    @Self() private control: NgControl,
    @Optional() @Host() private form: FormGroupDirective
) {
}

Данный параметр как и его предшественника мы обвешаем декораторами, а именно @Optional и @Host .

Первый говорит что зависимость опциональная. Это значит что если ангуляр не сможет запровайдить нам ее, мы получим null. Нужно это для поддержки standalone контролов без формы.

Второй говорит что искать ангуляр должен только в пределах хоста. Это значит что поиск в нашем будет идти в пределах темлпейта (html файла). Нужно это для того чтобы случайным образом не захватить ненужный нам атрибут.

Обновим ngOnInit следующим образом, добавив оператор merge:

public ngOnInit(): void {
    const ngSubmit = this.form?.ngSubmit ?? EMPTY;
    const valueChanges = this.control.valueChanges ?? EMPTY;

    merge(
        ngSubmit,
        valueChanges
    ).pipe(
        takeUntil(this.unsubscribe)
    ).subscribe(() => this.handleErrors());
}

Отлично, теперь можем приступать к реализации метода handleErrors, который мы объявили выше. В нем мы будем проверять есть ли у контрола ошибки, доставать их и получать сообщение об ошибке для дальнейшего отображения.

По итогам имеем следующий метод:

private handleErrors(): void {
  const errors = this.control.errors;
  if (errors) {
      const [code, params] = Object.entries(errors)[0];
      // some translation magic here
      this.setErrorText(errorText);
  } else {
      this.setErrorText('');
  }
}

Ну и теперь настал час самой главной части - отображение ошибки. Саму ошибку предлагаю инкапсулировать в отдельном dummy-компоненте, который выглядит следующим образом:

@Component({
    selector: 'app-error',
    templateUrl: './error.component.html',
    styleUrls: ['./error.component.css']
})
export class ErrorComponent {

    private _message = new BehaviorSubject('');
    private _visible = new BehaviorSubject(false);

    public visible$ = this._visible.asObservable();
    public message$ = this._message.asObservable()

    @Input()
    public set message(message: string) {
        this._message.next(message);
        this._visible.next(!!message);
    }

}
<div class="error" *ngIf="visible$ | async">
    {{ message$ | async }}
</div>

Тут были использованы subject-ы чтобы сделать компонент OnPush-friendly.

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

Для создания нам потребуется попросить у DI ещё пару вещей, а именно ViewContainerRef и ComponentFactoryResolver. Если вам когда-нибудь приходилось создавать динамически компоненты вы наверняка знаете, что эта парочка всегда идет вместе.

Если вы используете Angular 13 или выше вам будет достаточно только ViewContainerRef-а.

Тем временем наш финальный конструктор:

constructor(
    @Self() private control: NgControl,
    @Optional() @Host() private form: FormGroupDirective,
    private resolver: ComponentFactoryResolver,
    private viewContainerRef: ViewContainerRef,
    private translateService: TranslateService
) {
}

В компоненте сделаем приватное поле типа ComponentRef<ErrorComponent> куда будем сохранять ссылку на компонент:

private errorRef: ComponentRef<ErrorComponent> | null = null;

Обновим if-else блок в handleErrors:

if (errors) {
  // ...
} else if (this.errorRef) {
  // ...
}

Реализация setErrorText выглядит так:

private setErrorText(text: string): void {
    if (!this.errorRef) {
        const factory = this.resolver.resolveComponentFactory(ErrorComponent);
        this.errorRef = this.viewContainerRef.createComponent(factory);
    }

    this.errorRef.instance.message = text;
}

В нем мы просто обращаемся к инстансу компонента и сетим поле message. Ну и конечно же создаем компонент, если он ещё не был создан.

Если вы впервые видите динамическое создание компонента и/или у вас есть желание нырнуть в тему тему глубже, то могу порекомендовать данное выступление.

Для полноты картины предлагаю взглянуть на компонент целиком:

@Directive({
    selector: '[formControl], [formControlName]'
})
export class ControlErrorDirective implements OnInit, OnDestroy {

    private unsubscribe = new Subject();

    private errorRef: ComponentRef<ErrorComponent> | null = null;

    constructor(
        @Self() private control: NgControl,
        @Optional() @Host() private form: FormGroupDirective,
        private resolver: ComponentFactoryResolver,
        private viewContainerRef: ViewContainerRef,
        private translateService: TranslateService
    ) {
    }

    public ngOnInit(): void {
        const ngSubmit = this.form?.ngSubmit ?? EMPTY;
        const valueChanges = this.control.valueChanges ?? EMPTY;

        merge(
            ngSubmit,
            valueChanges
        ).pipe(
            takeUntil(this.unsubscribe)
        ).subscribe(() => this.handleErrors());
    }

    public ngOnDestroy(): void {
        this.unsubscribe.next();
        this.unsubscribe.complete();
    }

    private handleErrors(): void {
        const errors = this.control.errors;
        if (errors) {
            const { code, params } = this.convertToControlError(errors);
            const errorText = this.translateService.instant(code, params);
            this.setErrorText(errorText);
        } else if (this.errorRef) {
            this.setErrorText('');
        }
    }

    private convertToControlError(errors: ValidationErrors): ControlError {
        const [code, params] = Object.entries(errors)[0];
        return { code: `FORM_ERRORS.${code.toUpperCase()}`, params };
    }

    private setErrorText(text: string): void {
        if (!this.errorRef) {
            const factory = this.resolver.resolveComponentFactory(ErrorComponent);
            this.errorRef = this.viewContainerRef.createComponent(factory);
        }

        this.errorRef.instance.message = text;
    }
}

Если мы попробуем создать форму - то увидим что все работает и у нас появляются и исчезают сообщения об ошибках. Тоже самое работает и с одиночным контролом.

Демонстрация работы
Демонстрация работы

Но в данном подходе есть один небольшой нюанс - прямая привязка через formGroup, а не formGroupName во вложенных группах сломает часть формы и ошибки не будут отображаться при сабмите, поскольку мы получим неверную FormGroupDirective в компоненте.

При вложенных formGroup-ах вот так делать не стоит:

<form class="address-form" [formGroup]="shippingForm" (ngSubmit)="onSubmit()">

    <h1 class="example-title">Form example</h1>

    <!-- Don't use [formGroup]. Use fromGroupName="info" instead -->
    <div [formGroup]="shippingForm.get('info')">

        <div class="form-group">
            <label for="firstName">First Name</label>
            <input id="firstName" type="text" formControlName="firstName">
        </div>

        <div class="form-group">
            <label for="lastName">Last Name</label>
            <input id="lastName" type="text" formControlName="lastName">
        </div>

    </div>

</form>

Но не все так страшно, ведь мы всегда можем сделать собственную директиву, усадить ее на form, а там уже получить доступ к formGroupDirective или вообще к нативному ивенту submit. После чего нашу собственную директиву можно будет инжектить в ControlErrorDirective и смело использовать.

Интеграция с Angular Material

Для самописных контролов решение, описанное выше работает отлично, но как быть со сторонними библиотеками? Angular Material имеет собственный компонент для отображения ошибок, который контролирует он сам. Просто так применить наше решение уже не получится, но способ есть и его я опишу вкратце.

Для начала взглянем на типичный mat-form-field:

<mat-form-field class="search" appearance="fill">
    <mat-label>Search</mat-label>
    <input matInput [formControl]="searchControl">
    <mat-error matErrorMessage></mat-error>
</mat-form-field>

Как видим на предпоследней строке виднеется mat-error - тот самый компонент директиву, которая и должна содержать в себе ошибку. Также обратите внимание на атрибут matErrorMessage - это не материаловский атрибут, а селектор нашего компонента.

Вот кстати и он:

@Component({
    selector: '[matErrorMessage]',
    templateUrl: './mat-error-message.component.html',
    styleUrls: ['./mat-error-message.component.css']
})
export class MatErrorMessageComponent implements AfterViewInit, OnDestroy {

    private unsubscribe = new Subject();

    private inputRef!: MatFormFieldControl<MatInput>;

    private error = new BehaviorSubject('');
    public error$: Observable<string> = this.error.asObservable();

    constructor(
        private injector: Injector,
        private translateService: TranslateService
    ) {
    }

    public ngAfterViewInit(): void {
        this.inputRef = this.injector.get(MatFormField)._control;

        const valueChanges = this.inputRef.ngControl?.valueChanges ?? EMPTY;
        const ngSubmit = this.injector.get(FormGroupDirective, null)?.ngSubmit ?? EMPTY;

        merge(
            valueChanges,
            ngSubmit
        ).pipe(
            takeUntil(this.unsubscribe)
        ).subscribe(() => this.updateError())
    }

    public ngOnDestroy(): void {
        this.unsubscribe.next();
        this.unsubscribe.complete();
    }

    private updateError(): void {
        const errors = this.inputRef.ngControl?.errors;
        if (errors) {
            const { code, params } = this.convertToControlError(errors);
            const errorText = this.translateService.instant(code, params);
            this.error.next(errorText);
        }
    }

    private convertToControlError(errors: ValidationErrors): ControlError {
        const [code, params] = Object.entries(errors)[0];
        return { code: `FORM_ERRORS.${code.toUpperCase()}`, params };
    }
}

В нем мы получаем инжектор, а затем в хуке ngAfterViewInit получаем с помощью него сам MatFormField, из него вытягиваем контрол и спокойно сабимся на valueChanges, а затем делаем что и делали раньше.

Поскольку видимость ошибки контролирует MatFormField, наш темплейт выглядит довольно скромно:

{{ error$ | async }}

Вот и все, таким образом мы интегрировали автоматическое отображение ошибок с материалом.

Демонстрация интеграции с материаловский инпутом
Демонстрация интеграции с материаловский инпутом

Ах да, стоит сказать что если вы будете использовать оба подхода, то в контролах материала будете получать два сообщения об ошибке, так как наш селекторы formControl/formControlName никто не отменял. В качестве решения, просто добавьте дополнительный атрибут к селетору, как было показано выше.

Итоги

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

Комментарии (2)


  1. khis
    24.11.2021 21:44

    А насколько возможно такую же хитрость провернуть в AngularJS? Есть legacy-проект, в который просто просятся валидации, но AngualrJS, а "кописаста" уже надоела.


    1. neistow Автор
      24.11.2021 21:46

      Не работал с AngularJs поэтому не могу ничем помочь