Работа с формами Angular 4: вложение и проверка ввода

Опубликовано: 2022-03-11

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

Проверка вводимых пользователем данных является неотъемлемой частью любого надежного веб-приложения.

Формы в приложениях Angular могут агрегировать состояние всех входных данных, которые находятся в этой форме, и предоставлять общее состояние, такое как статус проверки полной формы. Это может быть очень удобно, чтобы решить, будет ли ввод данных пользователем принят или отклонен, без проверки каждого ввода отдельно.

Angular 4 Проверка ввода форм

В этой статье вы узнаете, как можно легко работать с формами и выполнять проверку формы в приложении Angular.

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

Угловые 4 формы

В Angular 4 формы обычно используют следующие четыре статуса:

  • valid — состояние валидности всех элементов управления формы, true, если все элементы управления действительны.

  • недействительный – инверсия valid ; true, если какой-то элемент управления недействителен

  • первозданный — дает статус о «чистоте» формы; true, если ни один элемент управления не был изменен

  • грязный - инверсия pristine ; true, если какой-то элемент управления был изменен

Давайте рассмотрим базовый пример формы:

 <form> <div> <label>Name</label> <input type="text" name="name"/> </div> <div> <label>Birth Year</label> <input type="text" name="birthYear"/> </div> <div> <h3>Location</h3> <div> <label>Country</label> <input type="text" name="country"/> </div> <div> <label>City</label> <input type="text" name="city"/> </div> </div> <div> <h3>Phone numbers</h3> <div> <label>Phone number 1</label> <input type="text" name="phoneNumber[1]"/> <button type="button">remove</button> </div> <button type="button">Add phone number</button> </div> <button type="submit">Register</button> <button type="button">Print to console</button> </form>

Спецификация для этого примера следующая:

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

  • рожденияГод - должно быть действительным числом, и пользователю должно быть не менее 18 и менее 85 лет.

  • страна - является обязательным, и, чтобы немного усложнить ситуацию, нам нужно подтверждение того, что если страна - Франция, то город должен быть Париж (скажем, наша услуга предлагается только в Париже)

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

  • Кнопка «Регистрация» активна только в том случае, если все входные данные действительны, и после нажатия она отправляет форму.

  • «Печать в консоль» просто печатает значение всех входов в консоль при нажатии.

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

Формы на основе шаблонов

Формы, управляемые шаблонами, очень похожи на формы в AngularJS (или Angular 1, как его называют некоторые). Итак, тот, кто работал с формами в AngularJS, будет хорошо знаком с этим подходом к работе с формами.

С введением модулей в Angular 4 стало обязательным, чтобы каждый конкретный тип формы находился в отдельном модуле, и мы должны явно определить, какой тип мы собираемся использовать, импортировав соответствующий модуль. Этот модуль для управляемых шаблоном форм называется FormsModule. При этом вы можете активировать формы на основе шаблона следующим образом:

 import {FormsModule} from '@angular/forms' import {NgModule} from '@angular/core' import {BrowserModule} from '@angular/platform-browser' import {AppComponent} from 'src/app.component'; @NgModule({ imports: [ BrowserModule, FormsModule ], declarations: [ AppComponent], bootstrap: [ AppComponent ] }) export class AppModule {}

Как показано в этом фрагменте кода, мы сначала должны импортировать модуль браузера, поскольку он «предоставляет услуги, необходимые для запуска и работы приложения браузера». (из документов Angular 4). Затем мы импортируем необходимый FormsModule для активации управляемых шаблоном форм. И последнее — это объявление корневого компонента AppComponent, где на следующих шагах мы реализуем форму.

Имейте в виду, что в этом примере и следующих примерах вы должны убедиться, что приложение правильно загружается с помощью метода platformBrowserDynamic .

 import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; import {AppModule} from './app.module'; platformBrowserDynamic().bootstrapModule(AppModule);

Можно предположить, что наш AppComponent (app.component.ts) выглядит примерно так:

 import {Component} from '@angular/core' @Component({ selector: 'my-app', templateUrl: 'src/app.component.tpl.html' }) export class AppComponent { }

Где шаблон этого компонента находится в app.component.tpl.html и мы можем скопировать исходный шаблон в этот файл.

Обратите внимание, что каждый элемент ввода должен иметь атрибут name , чтобы его можно было правильно идентифицировать в форме. Хотя это кажется простой HTML-формой, мы уже определили форму, поддерживаемую Angular 4 (возможно, вы ее еще не видите). Когда FormsModule импортируется, Angular 4 автоматически обнаруживает HTML-элемент form и прикрепляет компонент NgForm к этому элементу (с помощью selector компонента NgForm). Так обстоит дело в нашем примере. Хотя эта форма Angular 4 объявлена, на данный момент она не знает никаких входных данных, поддерживаемых Angular 4. Angular 4 не настолько агрессивен, чтобы регистрировать каждый input HTML-элемент в ближайшем предке form .

Ключом, который позволяет заметить элемент ввода как элемент Angular 4 и зарегистрировать его в компоненте NgForm, является директива NgModel. Итак, мы можем расширить шаблон app.component.tpl.html следующим образом:

 <form> .. <input type="text" name="name" ngModel> .. <input type="text" name="birthYear" ngModel > .. <input type="text" name="country" ngModel/> .. <input type="text" name="city" ngModel/> .. <input type="text" name="phoneNumber[1]" ngModel/> </form>

При добавлении директивы NgModel все входные данные регистрируются в компоненте NgForm. Благодаря этому мы определили полностью работающую форму Angular 4, и пока все хорошо, но у нас все еще нет способа получить доступ к компоненту NgForm и функциям, которые он предлагает. Две основные функции, предлагаемые NgForm:

  • Получение значений всех зарегистрированных элементов управления вводом

  • Получение общего состояния всех элементов управления

Чтобы открыть NgForm, мы можем добавить следующее к элементу <form>:

 <form #myForm="ngForm"> .. </form>

Это возможно благодаря exportAs декоратора Component .

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

 <form #myForm="ngForm"> .. <pre>{{myForm.value | json}}</pre> </form>

С помощью myForm.value мы получаем доступ к данным JSON, содержащим значения всех зарегистрированных входных данных, а с помощью {{myForm.value | json}} {{myForm.value | json}} , мы красиво печатаем JSON со значениями.

Что, если мы хотим иметь подгруппу входных данных из определенного контекста, заключенную в контейнер, и отдельный объект в значениях JSON, например, местоположение, содержащее страну и город или номера телефонов? Не переживайте — управляемые шаблонами формы в Angular 4 также охватывают это. Этого можно добиться с помощью директивы ngModelGroup .

 <form #myForm="ngForm"> .. <div ngModelGroup="location"> .. </div> </div ngModelGroup="phoneNumbers"> .. <div> .. </form>

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

 <div ngModelGroup="phoneNumbers"> <h3>Phone numbers</h3> <div *ngFor="let phoneId of phoneNumberIds; let i=index;"> <label>Phone number {{i + 1}}</label> <input type="text" name="phoneNumber[{{phoneId}}]" #phoneNumber="ngModel" ngModel/> <button type="button" (click)="remove(i); myForm.control.markAsTouched()">remove</button> </div> <button type="button" (click)="add(); myForm.control.markAsTouched()">Add phone number</button> </div>

myForm.control.markAsTouched() используется для того, чтобы форма touched , чтобы мы могли отображать ошибки в этот момент. Кнопки не активируют это свойство при нажатии, только входы. Чтобы сделать следующие примеры более понятными, я не буду добавлять эту строку в обработчик кликов для add() и remove() . Просто представьте, что он там. (Он присутствует в Plunkers.)

Нам также нужно обновить AppComponent , чтобы он содержал следующий код:

 private count:number = 1; phoneNumberIds:number[] = [1]; remove(i:number) { this.phoneNumberIds.splice(i, 1); } add() { this.phoneNumberIds.push(++this.count); }

Мы должны хранить уникальный идентификатор для каждого нового добавленного номера телефона, а в *ngFor отслеживать элементы управления номером телефона по их идентификатору (признаю, что это не очень приятно, но пока команда Angular 4 не реализует эту функцию, я боюсь , это лучшее, что мы можем сделать)

Хорошо, что у нас есть на данный момент, мы добавили поддерживаемую Angular 4 форму с входными данными, добавили определенную группу входных данных (местоположение и номера телефонов) и выставили форму в шаблоне. Но что, если мы хотим получить доступ к объекту NgForm в каком-то методе компонента? Мы рассмотрим два способа сделать это.

В первом случае NgForm, обозначенный в текущем примере как myForm , может быть передан в качестве аргумента функции, которая будет служить обработчиком события onSubmit формы. Для лучшей интеграции событие onSubmit wrapped событием Angular 4, специфичным для NgForm, с именем ngSubmit , и это правильный путь, если мы хотим выполнить какое-либо действие при отправке. Итак, пример теперь будет выглядеть так:

 <form #myForm="ngForm" (ngSubmit)="register(myForm)"> … </form>

У нас должен быть соответствующий метод register , реализованный в AppComponent. Что-то типа:

 register (myForm: NgForm) { console.log('Successful registration'); console.log(myForm); }

Таким образом, используя событие onSubmit, мы получаем доступ к компоненту NgForm только при выполнении отправки.

Второй способ — использовать запрос представления, добавляя декоратор @ViewChild к свойству компонента.

 @ViewChild('myForm') private myForm: NgForm;

При таком подходе нам разрешен доступ к форме независимо от того, было ли запущено событие onSubmit или нет.

Здорово! Теперь у нас есть полнофункциональная форма Angular 4 с доступом к форме в компоненте. Но вы заметили, что чего-то не хватает? Что, если пользователь введет что-то вроде «это не год» в поле «годы»? Да, вы поняли, нам не хватает проверки входных данных, и мы рассмотрим это в следующем разделе.

Проверка

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

Чтобы какое-то правило проверки применялось к некоторым входным данным, соответствующий валидатор должен быть связан с этими входными данными. Angular 4 уже предлагает набор общих валидаторов, таких как: required , maxLength , minLength

Итак, как мы можем связать валидатор с вводом? Ну, довольно легко; просто добавьте директиву валидатора в элемент управления:

 <input name="name" ngModel required/>

В этом примере ввод «имя» является обязательным. Давайте добавим некоторые проверки ко всем входным данным в нашем примере.

 <form #myForm="ngForm" (ngSubmit)="actionOnSubmit(myForm)" novalidate> <p>Is "myForm" valid? {{myForm.valid}}</p> .. <input type="text" name="name" ngModel required/> .. <input type="text" name="birthYear" ngModel required pattern="\\d{4,4}"/> .. <div ngModelGroup="location"> .. <input type="text" name="country" ngModel required/> .. <input type="text" name="city" ngModel/> </div> <div ngModelGroup="phoneNumbers"> .. <input type="text" name="phoneNumber[{{phoneId}}]" ngModel required/> .. </div> .. </form>

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

Мы сделали «имя» обязательным, поле «годы» обязательным и должно состоять только из цифр, требуется ввод страны, а также требуется номер телефона. Также мы печатаем статус валидности формы с помощью {{myForm.valid}} .

Улучшением этого примера было бы также показать, что не так с пользовательским вводом (а не просто показать общее состояние). Прежде чем мы продолжим добавлять дополнительную проверку, я хотел бы реализовать вспомогательный компонент, который позволит нам печатать все ошибки для предоставленного элемента управления.

 // show-errors.component.ts import { Component, Input } from '@angular/core'; import { AbstractControlDirective, AbstractControl } from '@angular/forms'; @Component({ selector: 'show-errors', template: ` <ul *ngIf="shouldShowErrors()"> <li *ngFor="let error of listOfErrors()">{{error}}</li> </ul> `, }) export class ShowErrorsComponent { private static readonly errorMessages = { 'required': () => 'This field is required', 'minlength': (params) => 'The min number of characters is ' + params.requiredLength, 'maxlength': (params) => 'The max allowed number of characters is ' + params.requiredLength, 'pattern': (params) => 'The required pattern is: ' + params.requiredPattern, 'years': (params) => params.message, 'countryCity': (params) => params.message, 'uniqueName': (params) => params.message, 'telephoneNumbers': (params) => params.message, 'telephoneNumber': (params) => params.message }; @Input() private control: AbstractControlDirective | AbstractControl; shouldShowErrors(): boolean { return this.control && this.control.errors && (this.control.dirty || this.control.touched); } listOfErrors(): string[] { return Object.keys(this.control.errors) .map(field => this.getMessage(field, this.control.errors[field])); } private getMessage(type: string, params: any) { return ShowErrorsComponent.errorMessages[type](params); } }

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

Сообщение для каждой ошибки ищется в карте предопределенных сообщений errorMessages (я добавил все сообщения заранее).

Этот компонент можно использовать следующим образом:

 <div> <label>Birth Year</label> <input type="text" name="birthYear" #birthYear="ngModel" ngModel required pattern="\\d{4,4}"/> <show-errors [control]="birthYear"></show-errors> </div>

Нам нужно предоставить NgModel для каждого ввода и передать его компоненту, который отображает все ошибки. Вы можете заметить, что в этом примере мы использовали шаблон, чтобы проверить, являются ли данные числом; что если пользователь введет «0000»? Это будет неверный ввод. Кроме того, нам не хватает валидаторов для уникального имени, странного ограничения страны (если страна = «Франция», то город должен быть «Париж»), шаблона для правильного номера телефона и проверки того, что хотя бы один номер телефона существует. Это подходящее время, чтобы взглянуть на пользовательские валидаторы.

Angular 4 предлагает интерфейс, который должен реализовать каждый пользовательский валидатор, интерфейс Validator (какой сюрприз!). Интерфейс валидатора в основном выглядит так:

 export interface Validator { validate(c: AbstractControl): ValidationErrors | null; registerOnValidatorChange?(fn: () => void): void; }

Где каждая конкретная реализация ДОЛЖНА реализовывать метод «проверки». Этот метод validate действительно интересен тем, что может быть получено на входе и что должно быть возвращено на выходе. Входными данными является AbstractControl, что означает, что аргумент может быть любым типом, расширяющим AbstractControl (FormGroup, FormControl и FormArray). Выходные данные метода validate должны быть null или undefined (без вывода), если пользовательский ввод действителен, или возвращать объект ValidationErrors , если пользовательский ввод недействителен. С этими знаниями теперь мы реализуем собственный валидатор birthYear .

 import { Directive } from '@angular/core'; import { NG_VALIDATORS, FormControl, Validator, ValidationErrors } from '@angular/forms'; @Directive({ selector: '[birthYear]', providers: [{provide: NG_VALIDATORS, useExisting: BirthYearValidatorDirective, multi: true}] }) export class BirthYearValidatorDirective implements Validator { validate(c: FormControl): ValidationErrors { const numValue = Number(c.value); const currentYear = new Date().getFullYear(); const minYear = currentYear - 85; const maxYear = currentYear - 18; const isValid = !isNaN(numValue) && numValue >= minYear && numValue <= maxYear; const message = { 'years': { 'message': 'The year must be a valid number between ' + minYear + ' and ' + maxYear } }; return isValid ? null : message; } }

Здесь нужно объяснить несколько вещей. Во-первых, вы можете заметить, что мы внедрили интерфейс Validator. Метод validate проверяет, находится ли пользователь в диапазоне от 18 до 85 лет на указанный год рождения. Если ввод действителен, то возвращается null , иначе возвращается объект, содержащий сообщение проверки. И последняя и самая важная часть — объявление этой директивы в качестве валидатора. Это делается в параметре «providers» декоратора @Directive. Этот валидатор предоставляется как одно значение мультипровайдера NG_VALIDATORS. Также не забудьте объявить эту директиву в NgModule. И теперь мы можем использовать этот валидатор следующим образом:

 <input type="text" name="birthYear" #year="ngModel" ngModel required birthYear/>

Да как просто!

Для номера телефона мы можем проверить формат номера телефона следующим образом:

 import { Directive } from '@angular/core'; import { NG_VALIDATORS, Validator, FormControl, ValidationErrors } from '@angular/forms'; @Directive({ selector: '[telephoneNumber]', providers: [{provide: NG_VALIDATORS, useExisting: TelephoneNumberFormatValidatorDirective, multi: true}] }) export class TelephoneNumberFormatValidatorDirective implements Validator { validate(c: FormControl): ValidationErrors { const isValidPhoneNumber = /^\d{3,3}-\d{3,3}-\d{3,3}$/.test(c.value); const message = { 'telephoneNumber': { 'message': 'The phone number must be valid (XXX-XXX-XXX, where X is a digit)' } }; return isValidPhoneNumber ? null : message; } }

Теперь идут две проверки, для страны и количества телефонных номеров. Заметили что-то общее для них обоих? Оба требуют более одного элемента управления для выполнения надлежащей проверки. Ну, вы помните интерфейс Validator, и что мы говорили о нем? Аргументом метода validate является AbstractControl, который может быть пользовательским вводом или самой формой. Это дает возможность реализовать валидатор, который использует несколько элементов управления для определения конкретного статуса валидации.

 import { Directive } from '@angular/core'; import { NG_VALIDATORS, Validator, FormGroup, ValidationErrors } from '@angular/forms'; @Directive({ selector: '[countryCity]', providers: [{provide: NG_VALIDATORS, useExisting: CountryCityValidatorDirective, multi: true}] }) export class CountryCityValidatorDirective implements Validator { validate(form: FormGroup): ValidationErrors { const countryControl = form.get('location.country'); const cityControl = form.get('location.city'); if (countryControl != null && cityControl != null) { const country = countryControl.value; const city = cityControl.value; let error = null; if (country === 'France' && city !== 'Paris') { error = 'If the country is France, the city must be Paris'; } const message = { 'countryCity': { 'message': error } }; return error ? message : null; } } }

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

Валидатор количества телефонных номеров будет выглядеть так:

 import { Directive } from '@angular/core'; import { NG_VALIDATORS, Validator, FormGroup, ValidationErrors, FormControl } from '@angular/forms'; @Directive({ selector: '[telephoneNumbers]', providers: [{provide: NG_VALIDATORS, useExisting: TelephoneNumbersValidatorDirective, multi: true}] }) export class TelephoneNumbersValidatorDirective implements Validator { validate(form: FormGroup): ValidationErrors { const message = { 'telephoneNumbers': { 'message': 'At least one telephone number must be entered' } }; const phoneNumbers = <FormGroup> form.get('phoneNumbers'); const hasPhoneNumbers = phoneNumbers && Object.keys(phoneNumbers.controls).length > 0; return hasPhoneNumbers ? null : message; } }

Мы можем использовать их так:

 <form #myForm="ngForm" countryCity telephoneNumbers> .. </form>

То же, что и валидаторы ввода, верно? Только сейчас применил форму.

Вы помните компонент ShowErrors? Мы реализовали его для работы с AbstractControlDirective, что означает, что мы можем повторно использовать его для отображения всех ошибок, связанных непосредственно с этой формой. Имейте в виду, что на данный момент единственными правилами проверки, непосредственно связанными с формой, являются Country-city и Telephone numbers (другие средства проверки связаны с конкретными элементами управления формы). Чтобы распечатать все ошибки формы, просто сделайте следующее:

 <form #myForm="ngForm" countryCity telephoneNumbers > <show-errors [control]="myForm"></show-errors> .. </form>

Последнее, что осталось, — это проверка уникальности имени. Это немного другое; чтобы проверить, является ли имя уникальным, скорее всего, потребуется вызов серверной части для проверки всех существующих имен. Это классифицируется как асинхронная операция. Для этой цели мы можем повторно использовать предыдущую технику для пользовательских валидаторов, просто заставив validate возвращать объект, который будет разрешен когда-нибудь в будущем (обещание или наблюдаемое). В нашем случае мы будем использовать обещание:

 import { Directive } from '@angular/core'; import { NG_ASYNC_VALIDATORS, Validator, FormControl, ValidationErrors } from '@angular/forms'; @Directive({ selector: '[uniqueName]', providers: [{provide: NG_ASYNC_VALIDATORS, useExisting: UniqueNameValidatorDirective, multi: true}] }) export class UniqueNameValidatorDirective implements Validator { validate(c: FormControl): ValidationErrors { const message = { 'uniqueName': { 'message': 'The name is not unique' } }; return new Promise(resolve => { setTimeout(() => { resolve(c.value === 'Existing' ? message : null); }, 1000); }); } }

Мы ждем 1 секунду, а затем возвращаем результат. Подобно валидаторам синхронизации, если обещание разрешается с помощью null , это означает, что проверка прошла успешно; если обещание разрешено чем-либо еще, то проверка не удалась. Также обратите внимание, что теперь этот валидатор зарегистрирован на другого мультипровайдера, NG_ASYNC_VALIDATORS . Одним из полезных свойств форм, касающихся асинхронных валидаторов, является свойство pending . Его можно использовать следующим образом:

 <button [disabled]="myForm.pending">Register</button>

Это отключит кнопку до тех пор, пока асинхронные валидаторы не будут разрешены.

Вот плункер, содержащий полный AppComponent, компонент ShowErrors и все валидаторы.

С помощью этих примеров мы рассмотрели большинство случаев работы с формами на основе шаблонов. Мы показали, что формы на основе шаблонов действительно похожи на формы в AngularJS (разработчикам AngularJS будет очень легко мигрировать). С этим типом формы довольно легко интегрировать формы Angular 4 с минимальным программированием, в основном с манипуляциями в HTML-шаблоне.

Реактивные формы

Реактивные формы были также известны как «управляемые моделями» формы, но мне нравится называть их «программными» формами, и скоро вы поймете, почему. Реактивные формы — это новый подход к поддержке форм Angular 4, поэтому, в отличие от форм, управляемых шаблонами, разработчики AngularJS не знакомы с этим типом.

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

 import {ReactiveFormsModule} from '@angular/forms' import {NgModule} from '@angular/core' import {BrowserModule} from '@angular/platform-browser' import {AppComponent} from 'src/app.component'; @NgModule({ imports: [ BrowserModule, ReactiveFormsModule ], declarations: [ AppComponent], bootstrap: [ AppComponent ] }) export class AppModule {}

Также не забудьте загрузить приложение.

Мы можем начать с того же AppComponent и шаблона, что и в предыдущем разделе.

На данный момент, если FormsModule не импортирован (и убедитесь, что это не так), у нас есть просто обычный элемент HTML-формы с парой элементов управления формы, никакой магии Angular.

Мы подошли к моменту, когда вы заметите, почему мне нравится называть этот подход «программным». Чтобы включить формы Angular 4, мы должны вручную объявить объект FormGroup и заполнить его такими элементами управления:

 import { FormGroup, FormControl, FormArray, NgForm } from '@angular/forms'; import { Component, OnInit } from '@angular/core'; @Component({ selector: 'my-app', templateUrl: 'src/app.component.html' }) export class AppComponent implements OnInit { private myForm: FormGroup; constructor() { } ngOnInit() { this.myForm = new FormGroup({ 'name': new FormControl(), 'birthYear': new FormControl(), 'location': new FormGroup({ 'country': new FormControl(), 'city': new FormControl() }), 'phoneNumbers': new FormArray([new FormControl('')]) }); } printMyForm() { console.log(this.myForm); } register(myForm: NgForm) { console.log('Registration successful.'); console.log(myForm.value); } }

printForm и register аналогичны предыдущим примерам и будут использоваться на следующих шагах. Здесь используются следующие типы ключей: FormGroup, FormControl и FormArray. Эти три типа — все, что нам нужно для создания действительной FormGroup. FormGroup проста; это простой контейнер элементов управления. FormControl также прост; это любое управление (например, ввод). И, наконец, FormArray — это часть головоломки, которую нам не хватало в подходе, основанном на шаблонах. FormArray позволяет поддерживать группу элементов управления без указания конкретного ключа для каждого элемента управления, в основном это массив элементов управления (кажется, идеально подходит для телефонных номеров, верно?).

При построении любого из этих трех типов помните об этом правиле троек. Конструктор для каждого типа получает три аргумента — value , валидатор или список валидаторов и асинхронный валидатор или список асинхронных валидаторов, определенных в коде:

 constructor(value: any, validator?: ValidatorFn | ValidatorFn[], asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[]);

Для FormGroup value — это объект, где каждая клавиша представляет имя элемента управления, а значение — это сам элемент управления.

Для FormArray value представляет собой массив элементов управления.

Для FormControl value является начальное значение или начальное состояние (объект, содержащий value и disabled свойство) элемента управления.

Мы создали объект FormGroup, но шаблон еще не знает об этом объекте. Связь между FormGroup в компоненте и шаблоном осуществляется с помощью четырех директив: formGroup , formControlName , formGroupName и formArrayName , которые используются следующим образом:

 <form [formGroup]="myForm" (ngSubmit)="register(myForm)"> <div> <label>Name</label> <input type="text" name="name" formControlName="name"> </div> <div> <label>Birth Year</label> <input type="text" name="birthYear" formControlName="birthYear"> </div> <div formGroupName="location"> <h3>Location</h3> <div> <label>Country</label> <input type="text" name="country" formControlName="country"> </div> <div> <label>City</label> <input type="text" name="city" formControlName="city"> </div> </div> <div formArrayName="phoneNumbers"> <h3>Phone numbers</h3> <div *ngFor="let phoneNumberControl of myForm.controls.phoneNumbers.controls; let i=index;"> <label>Phone number {{i + 1}}</label> <input type="text" name="phoneNumber[{{phoneId}}]" [formControlName]="i"> <button type="button" (click)="remove(i)">remove</button> </div> <button type="button" (click)="add()">Add phone number</button> </div> <pre>{{myForm.value | json}}</pre> <button type="submit">Register</button> <button type="button" (click)="printMyForm()">Print to console</button> </form>

Теперь, когда у нас есть FormArray, вы можете видеть, что мы можем использовать эту структуру для отображения всех телефонных номеров.

А теперь добавить поддержку добавления и удаления номеров телефонов (в компоненте):

 remove(i: number) { (<FormArray>this.myForm.get('phoneNumbers')).removeAt(i); } add() { (<FormArray>this.myForm.get('phoneNumbers')).push(new FormControl('')); }

Теперь у нас есть полностью функционирующая реактивная форма Angular 4. Обратите внимание на отличие от управляемых шаблоном форм, где FormGroup «создается в шаблоне» (путем сканирования структуры шаблона) и передается компоненту, в реактивных формах все наоборот, полная FormGroup создается в компонент, затем «передается в шаблон» и связывается с соответствующими элементами управления. Но опять же у нас есть та же проблема с проверкой, проблема, которая будет решена в следующем разделе.

Проверка

Когда дело доходит до проверки, реактивные формы гораздо более гибкие, чем формы на основе шаблонов. Без дополнительных изменений мы можем повторно использовать те же валидаторы, которые были реализованы ранее (для управляемых шаблоном). Итак, добавив директивы валидатора, мы можем активировать ту же проверку:

 <form [formGroup]="myForm" (ngSubmit)="register(myForm)" countryCity telephoneNumbers novalidate> <input type="text" name="name" formControlName="name" required uniqueName> <show-errors [control]="myForm.controls.name"></show-errors> .. <input type="text" name="birthYear" formControlName="birthYear" required birthYear> <show-errors [control]="myForm.controls.birthYear"></show-errors> .. <div formGroupName="location"> .. <input type="text" name="country" formControlName="country" required> <show-errors [control]="myForm.controls.location.controls.country"></show-errors> .. <input type="text" name="city" formControlName="city"> .. </div> <div formArrayName="phoneNumbers"> <h3>Phone numbers</h3> .. <input type="text" name="phoneNumber[{{phoneId}}]" [formControlName]="i" required telephoneNumber> <show-errors [control]="phoneNumberControl"></show-errors> .. </div> .. </form>

Имейте в виду, что теперь у нас нет директивы NgModel для передачи компоненту ShowErrors, но полная FormGroup уже создана, и мы можем передать правильный AbstractControl для получения ошибок.

Вот полностью работающий плункер с этим типом проверки для реактивных форм.

Но было бы неинтересно, если бы мы просто повторно использовали валидаторы, верно? Мы рассмотрим, как указать валидаторы при создании группы форм.

Помните правило «3s», которое мы упоминали о конструкторе для FormGroup, FormControl и FormArray? Да, мы сказали, что конструктор может получать функции валидатора. Итак, попробуем такой подход.

Во-первых, нам нужно извлечь функции validate всех валидаторов в класс, представляя их как статические методы:

 import { FormArray, FormControl, FormGroup, ValidationErrors } from '@angular/forms'; export class CustomValidators { static birthYear(c: FormControl): ValidationErrors { const numValue = Number(c.value); const currentYear = new Date().getFullYear(); const minYear = currentYear - 85; const maxYear = currentYear - 18; const isValid = !isNaN(numValue) && numValue >= minYear && numValue <= maxYear; const message = { 'years': { 'message': 'The year must be a valid number between ' + minYear + ' and ' + maxYear } }; return isValid ? null : message; } static countryCity(form: FormGroup): ValidationErrors { const countryControl = form.get('location.country'); const cityControl = form.get('location.city'); if (countryControl != null && cityControl != null) { const country = countryControl.value; const city = cityControl.value; let error = null; if (country === 'France' && city !== 'Paris') { error = 'If the country is France, the city must be Paris'; } const message = { 'countryCity': { 'message': error } }; return error ? message : null; } } static uniqueName(c: FormControl): Promise<ValidationErrors> { const message = { 'uniqueName': { 'message': 'The name is not unique' } }; return new Promise(resolve => { setTimeout(() => { resolve(c.value === 'Existing' ? message : null); }, 1000); }); } static telephoneNumber(c: FormControl): ValidationErrors { const isValidPhoneNumber = /^\d{3,3}-\d{3,3}-\d{3,3}$/.test(c.value); const message = { 'telephoneNumber': { 'message': 'The phone number must be valid (XXX-XXX-XXX, where X is a digit)' } }; return isValidPhoneNumber ? null : message; } static telephoneNumbers(form: FormGroup): ValidationErrors { const message = { 'telephoneNumbers': { 'message': 'At least one telephone number must be entered' } }; const phoneNumbers = <FormArray>form.get('phoneNumbers'); const hasPhoneNumbers = phoneNumbers && Object.keys(phoneNumbers.controls).length > 0; return hasPhoneNumbers ? null : message; } }

Now we can change the creation of 'myForm' to:

 this.myForm = new FormGroup({ 'name': new FormControl('', Validators.required, CustomValidators.uniqueName), 'birthYear': new FormControl('', [Validators.required, CustomValidators.birthYear]), 'location': new FormGroup({ 'country': new FormControl('', Validators.required), 'city': new FormControl() }), 'phoneNumbers': new FormArray([this.buildPhoneNumberComponent()]) }, Validators.compose([CustomValidators.countryCity, CustomValidators.telephoneNumbers]) );

Видеть? The rule of “3s,” when defining a FormControl, multiple validators can be declared in an array, and if we want to add multiple validators to a FormGroup they must be “merged” using Validators.compose (also Validators.composeAsync is available). And, that's it, validation should be working completely. There's a Plunker for this example as well.

This goes out to everybody that hates the “new” word. For working with the reactive forms, there's a shortcut provided—a builder, to be more precise. The FormBuilder allows creating the complete FormGroup by using the “builder pattern.” And that can be done by changing the FormGroup construction like this:

 constructor(private fb: FormBuilder) { } ngOnInit() { this.myForm = this.fb.group({ 'name': ['', Validators.required, CustomValidators.uniqueName], 'birthYear': ['', [Validators.required, CustomValidators.birthYear]], 'location': this.fb.group({ 'country': ['', Validators.required], 'city': '' }), 'phoneNumbers': this.fb.array([this.buildPhoneNumberComponent()]) }, { validator: Validators.compose([CustomValidators.countryCity, CustomValidators.telephoneNumbers]) } ); }

Not a very big improvement from the instantiation with “new,” but there it is. And, don't worry, there's a Plunker for this also.

In this second section, we had a look at reactive forms in Angular 4. As you may notice, it is a completely new approach towards adding support for forms. Even though it seems verbose, this approach gives the developer total control over the underlying structure that enables forms in Angular 4. Also, since the reactive forms are created manually in the component, they are exposed and provide an easy way to be tested and controlled, while this was not the case with the template-driven forms.

Nesting Forms

Nesting forms is in some cases useful and a required feature, mainly when the state (eg, validity) of a sub-group of controls needs to determined. Think about a tree of components; we might be interested in the validity of a certain component in the middle of that hierarchy. That would be really hard to achieve if we had a single form at the root component. But, oh boy, it is a sensitive manner on a couple of levels. First, nesting real HTML forms, according to the HTML specification, is not allowed. We might try to nest <form> elements. In some browsers it might actually work, but we cannot be sure that it will work on all browsers, since it is not in the HTML spec. In AngularJS, the way to work around this limitation was to use the ngForm directive, which offered the AngularJS form functionalities (just grouping of the controls, not all form capabilities like posting to the server) but could be placed on any element. Also, in AngularJS, nesting of forms (when I say forms, I mean NgForm) was available out of the box. Just by declaring a tree of couple of elements with the ngForm directive, the state of each form was propagated upwards to the root element.

In the next section, we will have a look at a couple options on how to nest forms. I like to point out that we can differentiate two types of nesting: within the same component and across different components.

Nesting within the Same Component

If you take a look at the example that we implemented with the template-driven and the reactive approach, you will notice that we have two inner containers of controls, the “location” and the “phone numbers.” To create that container, to store the values in a separate property object, we used the NgModelGroup, FormGroupName, and the FormArrayName directives. If you have a good look at the definition of each directive, you may notice that each one of them extends the ControlContainer class (directly or indirectly). Well, what do you know, it turns out this is enough to provide the functionality that we require, wrapping up the state of all inner controls and propagating that state to the parent.

For the template-driven form, we need to do the following changes:

 <form #myForm="ngForm" (ngSubmit)="register(myForm)" novalidate> .. <div ngModelGroup="location" #location="ngModelGroup" countryCity> .. <show-errors [control]="location"></show-errors> </div> <div ngModelGroup="phoneNumbers" #phoneNumbers="ngModelGroup" telephoneNumbers> .. <show-errors [control]="phoneNumbers"></show-errors> </div> </form>

We added the ShowErrors component to each group, to show the errors directly associated with that group only. Since we moved the countryCity and telephoneNumbers validators to a different level, we also need to update them appropriately:

 // country-city-validator.directive.ts let countryControl = form.get('country'); let cityControl = form.get('city');

And telephone-numbers-validator.directive.ts to:

 let phoneNumbers = form.controls; let hasPhoneNumbers = phoneNumbers && Object.keys(phoneNumbers).length > 0;

You can try the full example with template-driven forms in this Plunker.

And for the reactive forms, we will need some similar changes:

 <form [formGroup]="myForm" (ngSubmit)="register(myForm)" novalidate> .. <div formGroupName="location"> .. <show-errors [control]="myForm.controls.location"></show-errors> </div> <div formArrayName="phoneNumbers"> .. <show-errors [control]="myForm.controls.phoneNumbers"></show-errors> </div> .. </form>

The same changes from country-city-validator.directive.ts and telephone-numbers-validator.directive.ts are required for the countryCity and telephoneNumbers validators in CustomValidators to properly locate the controls.

And lastly, we need to modify the construction of the FormGroup to:

 this.myForm = new FormGroup({ 'name': new FormControl('', Validators.required, CustomValidators.uniqueName), 'birthYear': new FormControl('', [Validators.required, CustomValidators.birthYear]), 'location': new FormGroup({ 'country': new FormControl('', Validators.required), 'city': new FormControl() }, CustomValidators.countryCity), 'phoneNumbers': new FormArray([this.buildPhoneNumberComponent()], CustomValidators.telephoneNumbers) });

And there you have it—we've improved the validation for the reactive forms as well and as expected, the Plunker for this example.

Nesting across Different Components

It may come as a shock to all AngularJS developers, but in Angular 4, nesting of forms across different component doesn't work out of the box. I'm going to be straight honest with you; my opinion is that nesting is not supported for a reason (probably not because the Angular 4 team just forgot about it). Angular4's main enforced principle is a one-way data flow, top to bottom through the tree of components. The whole framework was designed like that, where the vital operation, the change detection, is executed in the same manner, top to bottom. If we follow this principle completely, we should have no issues, and all changes should be resolved within one full detection cycle. That's the idea, at least. In order to check that one-way data flow is implemented correctly, the nice guys in the Angular 4 team implemented a feature that after each change detection cycle, while in development mode, an additional round of change detection is triggered to check that no binding was changed as a result of reverse data propagation. What this means, let's think about a tree of components (C1, C2, C3, C4) as in Fig. 1, the change detection starts at the C1 component, continues at the C2 component and ends in the C3 component.

A tree of nested components holding a form.

If we have some method in C3 with a side effect that changes some binding in C1, that means that we are pushing data upwards, but the change detection for C1 already passed. When working in dev mode, the second round kicks in and notices a change in C1 that came as a result of a method execution in some child component. Then you are in trouble and you'll probably see the “Expression has changed after it was checked” exception. You could just turn off the development mode and there will be no exception, but the problem will not be solved; plus, how would you sleep at night, just sweeping all your problems under the rug like that?

Как только вы это узнаете, подумайте о том, что мы делаем, если агрегируем состояние форм. Правильно, данные перемещаются вверх по дереву компонентов. Даже при работе с одноуровневыми формами интеграция элементов управления формы ( ngModel ) и самой формы не так приятна. Они запускают дополнительный цикл обнаружения изменений при регистрации или обновлении значения элемента управления (это делается с использованием разрешенного обещания, но держите его в секрете). Зачем нужен дополнительный раунд? Ну по той же причине данные идут вверх, от контрола к форме. Но, может быть, иногда вложение форм в несколько компонентов является необходимой функцией, и нам нужно придумать решение для поддержки этого требования.

Из того, что мы знаем до сих пор, первая идея, которая приходит на ум, — использовать реактивные формы, создать полное дерево форм в каком-то корневом компоненте, а затем передать дочерние формы дочерним компонентам в качестве входных данных. Таким образом, вы тесно связали родитель с дочерними компонентами и загромождали бизнес-логику корневого компонента обработкой создания всех дочерних форм. Да ладно, мы профессионалы, я уверен, что мы сможем найти способ создания полностью изолированных компонентов с формами и обеспечить способ, которым форма просто распространяет состояние тому, кто является родителем.

При всем этом, вот директива, которая позволяет вкладывать формы Angular 4 (реализована, потому что это было необходимо для проекта):

 import { OnInit, OnDestroy, Directive, SkipSelf, Optional, Attribute, Injector, Input } from '@angular/core'; import { NgForm, FormArray, FormGroup, AbstractControl } from '@angular/forms'; const resolvedPromise = Promise.resolve(null); @Directive({ selector: '[nestableForm]' }) export class NestableFormDirective implements OnInit, OnDestroy { private static readonly FORM_ARRAY_NAME = 'CHILD_FORMS'; private currentForm: FormGroup; @Input() private formGroup: FormGroup; constructor(@SkipSelf() @Optional() private parentForm: NestableFormDirective, private injector: Injector, @Attribute('rootNestableForm') private isRoot) { } ngOnInit() { if (!this.currentForm) { // NOTE: at this point both NgForm and ReactiveFrom should be available this.executePostponed(() => this.resolveAndRegister()); } } ngOnDestroy() { this.executePostponed(() => this.parentForm.removeControl(this.currentForm)); } public registerNestedForm(control: AbstractControl): void { // NOTE: prevent circular reference (adding to itself) if (control === this.currentForm) { throw new Error('Trying to add itself! Nestable form can be added only on parent "NgForm" or "FormGroup".'); } (<FormArray>this.currentForm.get(NestableFormDirective.FORM_ARRAY_NAME)).push(control); } public removeControl(control: AbstractControl): void { const array = (<FormArray>this.currentForm.get(NestableFormDirective.FORM_ARRAY_NAME)); const idx = array.controls.indexOf(control); array.removeAt(idx); } private resolveAndRegister(): void { this.currentForm = this.resolveCurrentForm(); this.currentForm.addControl(NestableFormDirective.FORM_ARRAY_NAME, new FormArray([])); this.registerToParent(); } private resolveCurrentForm(): FormGroup { // NOTE: template-driven or model-driven => determined by the formGroup input return this.formGroup ? this.formGroup : this.injector.get(NgForm).control; } private registerToParent(): void { if (this.parentForm != null && !this.isRoot) { this.parentForm.registerNestedForm(this.currentForm); } } private executePostponed(callback: () => void): void { resolvedPromise.then(() => callback()); } }

Пример в следующем GIF показывает один main компонент, содержащий form-1 , и внутри этой формы есть еще один вложенный компонент, component-2 . component-2 содержит form-2 , который имеет вложенные form-2.1 , form-2.2 , и компонент ( component-3 ), который имеет в себе дерево реактивной формы и компонент ( component-4 ), который содержит форму, которая изолирован от всех других форм. Я знаю, это довольно запутанно, но я хотел сделать довольно сложный сценарий, чтобы показать функциональность этой директивы.

Сложный случай проверки формы Angular с несколькими компонентами

Пример реализован в этом плункере.

Особенности, которые он предлагает:

  • Включает вложенность, добавляя директивуnestableForm к элементам: form, ngForm, [ngForm], [formGroup]

  • Работает с управляемыми шаблонами и реактивными формами

  • Позволяет построить дерево форм, охватывающее несколько компонентов.

  • Изолирует поддерево форм с rootNestableForm=”true” (оно не будет регистрироваться в родительской вложенной форме)

Эта директива позволяет форме в дочернем компоненте регистрироваться в первой родительской вложенной форме, независимо от того, объявлена ​​ли родительская форма в том же компоненте или нет. Мы углубимся в детали реализации.

Прежде всего, давайте посмотрим на конструктор. Первый аргумент:

 @SkipSelf() @Optional() private parentForm: NestableFormDirective

Это ищет первого родителя NestableFormDirective. @SkipSelf, чтобы не совпадать с самим собой, и @Optional, потому что он может не найти родителя в случае корневой формы. Теперь у нас есть ссылка на родительскую вложенную форму.

Второй аргумент:

 private injector: Injector

Инжектор используется для получения текущего provider FormGroup (шаблонного или реактивного).

И последний аргумент:

 @Attribute('rootNestableForm') private isRoot

чтобы получить значение, определяющее, изолирована ли эта форма от дерева форм.

Далее, в ngInit в качестве отложенного действия (помните обратный поток данных?), разрешается текущая группа FormGroup, в этой группе форм регистрируется новый элемент управления FormArray с именем CHILD_FORMS (где будут зарегистрированы дочерние формы) и, как последнее действие, текущая FormGroup зарегистрирована как дочерняя для родительской вложенной формы.

Действие ngOnDestroy выполняется при уничтожении формы. При уничтожении, опять же как отложенное действие, текущая форма удаляется из родителя (отмена регистрации).

Директиву для вложенных форм можно дополнительно настроить для конкретных нужд — например, удалить поддержку реактивных форм, зарегистрировать каждую дочернюю форму под определенным именем (не в массиве CHILD_FORMS) и т. д. Эта реализация директивыnestableForm удовлетворила требованиям проекта и представлена ​​здесь как таковая. Он охватывает некоторые основные случаи, такие как добавление новой формы или динамическое удаление существующей формы (*ngIf) и передача состояния формы родительскому элементу. В основном это сводится к операциям, которые можно разрешить в рамках одного цикла обнаружения изменений (с отсрочкой или без).

Если вам нужен более сложный сценарий, например добавление условной проверки к некоторым входным данным (например, [required]="someCondition"), для которого потребуется 2 раунда обнаружения изменений, он не будет работать из-за правила «разрешение одного цикла обнаружения». навязанный Angular 4.

В любом случае, если вы планируете использовать эту директиву или внедрить какое-либо другое решение, будьте очень осторожны в отношении того, что было упомянуто в связи с обнаружением изменений. На данный момент так реализован Angular 4. Это может измениться в будущем — мы не можем знать. Текущая настройка и принудительное ограничение в Angular 4, упомянутые в этой статье, могут быть недостатком или преимуществом. Это еще предстоит увидеть.

Формы стали проще с Angular 4

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

Несмотря на все различные сообщения, связанные с формами Angular 4 (или любой другой темой Angular 4, если уж на то пошло), на мой взгляд, лучшей отправной точкой является официальная документация Angular 4. Кроме того, у ребят из Angular есть хорошая документация в коде. Много раз я находил решение, просто просматривая их исходный код и документацию, без поиска в Google или чего-то еще. Что касается вложенности форм, обсуждавшейся в предыдущем разделе, я считаю, что любой разработчик AngularJS, который начинает изучать Angular 4, в какой-то момент наткнется на эту проблему, которая как бы вдохновила меня на написание этого поста.

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

Плункеры, используемые в этом посте

  • Формы на основе шаблонов

  • Реактивные формы, валидаторы шаблонов

  • Реактивные формы, валидаторы кода

  • Реактивные формы, конструктор форм

  • Формы на основе шаблонов, вложенные в один и тот же компонент

  • Реактивные формы, вложенные в один и тот же компонент

  • Вложенные формы через дерево компонентов

Связанный: Проверка формы Smart Node.js