Trabajar con formularios angulares 4: anidamiento y validación de entrada
Publicado: 2022-03-11En la web, algunos de los primeros elementos de entrada del usuario fueron un botón, una casilla de verificación, entrada de texto y botones de radio. Hasta el día de hoy, estos elementos todavía se usan en las aplicaciones web modernas a pesar de que el estándar HTML ha recorrido un largo camino desde su definición inicial y ahora permite todo tipo de interacciones sofisticadas.
La validación de las entradas de los usuarios es una parte esencial de cualquier aplicación web robusta.
Los formularios en las aplicaciones Angular pueden agregar el estado de todas las entradas que se encuentran en ese formulario y proporcionar un estado general como el estado de validación del formulario completo. Esto puede ser muy útil para decidir si la entrada del usuario será aceptada o rechazada sin verificar cada entrada por separado.
En este artículo, aprenderá cómo puede trabajar con formularios y realizar la validación de formularios con facilidad en su aplicación Angular.
En Angular 4, hay dos tipos diferentes de formularios disponibles para trabajar: formularios basados en plantillas y formularios reactivos. Revisaremos cada tipo de formulario utilizando el mismo ejemplo para ver cómo se pueden implementar las mismas cosas de diferentes maneras. Más adelante, en el artículo, veremos un enfoque novedoso sobre cómo configurar y trabajar con formularios anidados.
Angular 4 formas
En Angular 4, los formularios suelen utilizar los siguientes cuatro estados:
válido: estado de validez de todos los controles de formulario, verdadero si todos los controles son válidos
inválido – inverso de
valid; verdadero si algún control no es válidoprístino: da un estado sobre la "limpieza" del formulario; verdadero si no se modificó ningún control
sucio – inverso de
pristine; verdadero si se modificó algún control
Veamos un ejemplo básico de un formulario:
<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>La especificación para este ejemplo es la siguiente:
nombre - es requerido y único entre todos los usuarios registrados
birthYear - debe ser un número válido y el usuario debe tener al menos 18 y menos de 85 años
país: es obligatorio, y solo para complicar un poco las cosas, necesitamos una validación de que si el país es Francia, entonces la ciudad debe ser París (digamos que nuestro servicio se ofrece solo en París)
phoneNumber: cada número de teléfono debe seguir un patrón específico, debe haber al menos un número de teléfono y el usuario puede agregar un número de teléfono nuevo o eliminar uno existente.
El botón "Registrarse" está habilitado solo si todas las entradas son válidas y, una vez que se hace clic, envía el formulario.
"Imprimir en la consola" simplemente imprime el valor de todas las entradas en la consola cuando se hace clic.
El objetivo final es implementar completamente la especificación definida.
Formularios basados en plantillas
Los formularios basados en plantillas son muy similares a los formularios en AngularJS (o Angular 1, como algunos lo llaman). Entonces, alguien que haya trabajado con formularios en AngularJS estará muy familiarizado con este enfoque para trabajar con formularios.
Con la introducción de módulos en Angular 4, se impone que cada tipo específico de formulario esté en un módulo separado y debemos definir explícitamente qué tipo vamos a usar importando el módulo adecuado. Ese módulo para los formularios basados en plantillas es FormsModule. Dicho esto, puede activar los formularios basados en plantillas de la siguiente manera:
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 {}Como se presenta en este fragmento de código, primero debemos importar el módulo del navegador, ya que "brinda servicios que son esenciales para iniciar y ejecutar una aplicación de navegador". (de los documentos de Angular 4). Luego importamos el FormsModule requerido para activar los formularios basados en plantillas. Y por último está la declaración del componente raíz, AppComponent, donde en los siguientes pasos implementaremos el formulario.
Tenga en cuenta que en este ejemplo y en los siguientes, debe asegurarse de que la aplicación se haya iniciado correctamente mediante el método platformBrowserDynamic .
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; import {AppModule} from './app.module'; platformBrowserDynamic().bootstrapModule(AppModule);Podemos suponer que nuestro AppComponent (app.component.ts) se parece a esto:
import {Component} from '@angular/core' @Component({ selector: 'my-app', templateUrl: 'src/app.component.tpl.html' }) export class AppComponent { }Donde la plantilla de este componente está en app.component.tpl.html y podemos copiar la plantilla inicial a este archivo.
Tenga en cuenta que cada elemento de entrada debe tener el atributo de name para identificarse correctamente dentro del formulario. Aunque esto parece un formulario HTML simple, ya hemos definido un formulario compatible con Angular 4 (tal vez aún no lo vea). Cuando se importa FormsModule, Angular 4 detecta automáticamente un elemento HTML de form y adjunta el componente NgForm a ese elemento (mediante el selector del componente NgForm). Ese es el caso de nuestro ejemplo. Aunque este formulario Angular 4 está declarado, en este momento no conoce ninguna entrada compatible con Angular 4. Angular 4 no es tan invasivo como para registrar cada elemento HTML de input en el ancestro de form más cercano.
La clave que permite que un elemento de entrada sea notado como un elemento Angular 4 y registrado en el componente NgForm es la directiva NgModel. Entonces, podemos extender la plantilla app.component.tpl.html de la siguiente manera:
<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>Al agregar la directiva NgModel, todas las entradas se registran en el componente NgForm. Con esto, hemos definido un formulario Angular 4 que funciona completamente y hasta ahora, muy bien, pero aún no tenemos una forma de acceder al componente NgForm y las funcionalidades que ofrece. Las dos funcionalidades principales que ofrece NgForm son:
Recuperación de los valores de todos los controles de entrada registrados
Recuperación del estado general de todos los controles
Para exponer el NgForm, podemos agregar lo siguiente al elemento <form>:
<form #myForm="ngForm"> .. </form> Esto es posible gracias a la propiedad exportAs del decorador de Component .
Una vez hecho esto, podemos acceder a los valores de todos los controles de entrada y extender la plantilla a:
<form #myForm="ngForm"> .. <pre>{{myForm.value | json}}</pre> </form> Con myForm.value estamos accediendo a datos JSON que contienen los valores de todas las entradas registradas, y con {{myForm.value | json}} {{myForm.value | json}} , estamos imprimiendo bastante el JSON con los valores.
¿Qué pasa si queremos tener un subgrupo de entradas de un contexto específico envuelto en un contenedor y un objeto separado en los valores JSON, por ejemplo, la ubicación que contiene el país y la ciudad o los números de teléfono? No se estrese: los formularios basados en plantillas en Angular 4 también tienen eso cubierto. La forma de lograr esto es usando la directiva ngModelGroup .
<form #myForm="ngForm"> .. <div ngModelGroup="location"> .. </div> </div ngModelGroup="phoneNumbers"> .. <div> .. </form>Lo que nos falta ahora es una forma de agregar varios números de teléfono. La mejor manera de hacer esto hubiera sido usar una matriz, como la mejor representación de un contenedor iterable de múltiples objetos, pero al momento de escribir este artículo, esa función no está implementada para los formularios basados en plantillas. Entonces, tenemos que aplicar una solución alternativa para que esto funcione. La sección de números de teléfono debe actualizarse de la siguiente manera:
<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> El myForm.control.markAsTouched() se usa para hacer que el formulario sea touched para que podamos mostrar los errores en ese momento. Los botones no activan esta propiedad al hacer clic, solo las entradas. Para que los siguientes ejemplos sean más claros, no agregaré esta línea en el controlador de clics para add() y remove() . Imagínense que está ahí. (Está presente en los Plunkers.)
También necesitamos actualizar AppComponent para que contenga el siguiente código:
private count:number = 1; phoneNumberIds:number[] = [1]; remove(i:number) { this.phoneNumberIds.splice(i, 1); } add() { this.phoneNumberIds.push(++this.count); } Debemos almacenar una identificación única para cada nuevo número de teléfono agregado, y en el *ngFor , rastrear los controles de número de teléfono por su identificación (Admito que no es muy agradable, pero hasta que el equipo de Angular 4 implemente esta característica, me temo , es lo mejor que podemos hacer)
De acuerdo, qué tenemos hasta ahora, agregamos el formulario compatible con Angular 4 con entradas, agregamos una agrupación específica de las entradas (ubicación y números de teléfono) y expusimos el formulario dentro de la plantilla. Pero, ¿y si quisiéramos acceder al objeto NgForm en algún método del componente? Echaremos un vistazo a dos maneras de hacer esto.
Para la primera forma, el NgForm, etiquetado myForm en el ejemplo actual, se puede pasar como argumento a la función que servirá como controlador para el evento onSubmit del formulario. Para una mejor integración, el evento onSubmit está wrapped por un evento específico de NgForm de Angular 4 llamado ngSubmit , y este es el camino correcto si queremos ejecutar alguna acción al enviar. Entonces, el ejemplo ahora se verá así:
<form #myForm="ngForm" (ngSubmit)="register(myForm)"> … </form> Debemos tener un register de método correspondiente, implementado en AppComponent. Algo como:
register (myForm: NgForm) { console.log('Successful registration'); console.log(myForm); }De esta manera, al aprovechar el evento onSubmit, tenemos acceso al componente NgForm solo cuando se ejecuta el envío.
La segunda forma es usar una consulta de vista agregando el decorador @ViewChild a una propiedad del componente.
@ViewChild('myForm') private myForm: NgForm;Con este enfoque, se nos permite el acceso al formulario sin importar si el evento onSubmit se activó o no.
¡Genial! Ahora tenemos un formulario Angular 4 completamente funcional con acceso al formulario en el componente. Pero, ¿notas que falta algo? ¿Qué pasa si el usuario ingresa algo como “este-no-es-un-año” en la entrada de “años”? Sí, lo entendiste, nos falta la validación de las entradas y lo cubriremos en la siguiente sección.
Validación
La validación es realmente importante para cada aplicación. Siempre queremos validar la entrada del usuario (no podemos confiar en el usuario) para evitar enviar/guardar datos no válidos y debemos mostrar algún mensaje significativo sobre el error para guiar adecuadamente al usuario a ingresar datos válidos.
Para que se aplique alguna regla de validación en alguna entrada, el validador adecuado debe estar asociado con esa entrada. Angular 4 ya ofrece un conjunto de validadores comunes como: required , maxLength , minLength …
Entonces, ¿cómo podemos asociar un validador con una entrada? Bueno, bastante fácil; simplemente agregue la directiva de validación al control:
<input name="name" ngModel required/>Este ejemplo hace que la entrada de "nombre" sea obligatoria. Agreguemos algunas validaciones a todas las entradas en nuestro ejemplo.
<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>Nota:
novalidatese usa para deshabilitar la validación de formulario nativo del navegador.
Hemos hecho que el "nombre" sea obligatorio, el campo "años" es obligatorio y debe constar solo de números, se requiere la entrada del país y también se requiere el número de teléfono. Además, imprimimos el estado de validez del formulario con {{myForm.valid}} .
Una mejora de este ejemplo sería mostrar también lo que está mal con la entrada del usuario (no solo mostrar el estado general). Antes de continuar agregando validación adicional, me gustaría implementar un componente auxiliar que nos permita imprimir todos los errores para un control proporcionado.
// 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); } }La lista con errores se muestra solo si hay algunos errores existentes y la entrada está tocada o sucia.
El mensaje de cada error se busca en un mapa de mensajes predefinidos errorMessages (he agregado todos los mensajes por adelantado).
Este componente se puede utilizar de la siguiente manera:
<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>Necesitamos exponer el NgModel para cada entrada y pasarlo al componente que genera todos los errores. Puedes notar que en este ejemplo hemos usado un patrón para verificar si los datos son un número; ¿Qué sucede si el usuario ingresa "0000"? Esta sería una entrada no válida. Además, nos faltan los validadores para un nombre único, la extraña restricción del país (si país='Francia', entonces la ciudad debe ser 'París'), el patrón para un número de teléfono correcto y la validación de que al menos un número de teléfono existe Este es el momento adecuado para echar un vistazo a los validadores personalizados.
Angular 4 ofrece una interfaz que cada validador personalizado debe implementar, la interfaz Validator (¡qué sorpresa!). La interfaz de Validator básicamente se ve así:
export interface Validator { validate(c: AbstractControl): ValidationErrors | null; registerOnValidatorChange?(fn: () => void): void; } Donde cada implementación concreta DEBE implementar el método 'validar'. Este método de validate es realmente interesante sobre lo que se puede recibir como entrada y lo que se debe devolver como salida. La entrada es un AbstractControl, lo que significa que el argumento puede ser de cualquier tipo que amplíe AbstractControl (FormGroup, FormControl y FormArray). La salida del método de validate debe ser null o undefined (sin salida) si la entrada del usuario es válida, o devolver un objeto ValidationErrors si la entrada del usuario no es válida. Con este conocimiento, ahora implementaremos un validador de año de birthYear personalizado.
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; } } Hay algunas cosas que explicar aquí. Primero, puede notar que implementamos la interfaz Validator. El método de validate verifica si el usuario tiene entre 18 y 85 años para el año de nacimiento ingresado. Si la entrada es válida, se devuelve un null o se devuelve un objeto que contiene el mensaje de validación. Y la última y más importante parte es declarar esta directiva como un Validador. Eso se hace en el parámetro "proveedores" del decorador @Directive. Este validador se proporciona como un valor de NG_VALIDATORS de múltiples proveedores. Además, no olvide declarar esta directiva en el NgModule. Y ahora podemos usar este validador de la siguiente manera:
<input type="text" name="birthYear" #year="ngModel" ngModel required birthYear/>¡Sí, tan simple como eso!
Para el número de teléfono, podemos validar el formato del número de teléfono así:
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; } } Ahora vienen las dos validaciones, la del país y la del número de teléfonos. ¿Notas algo común para ambos? Ambos requieren más de un control para realizar una validación adecuada. Bueno, ¿recuerdas la interfaz de Validator y lo que dijimos al respecto? El argumento del método de validate es AbstractControl, que puede ser una entrada del usuario o el formulario en sí. Esto crea la oportunidad de implementar un validador que utiliza múltiples controles para determinar el estado de validación concreto.
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; } } }Hemos implementado un nuevo validador, validador de país-ciudad. Puede notar que ahora como argumento el método de validación recibe un FormGroup y de ese FormGroup podemos recuperar las entradas requeridas para la validación. El resto de las cosas son muy similares al validador de entrada única.
El validador para la cantidad de números de teléfono se verá así:
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; } }Podemos usarlos así:
<form #myForm="ngForm" countryCity telephoneNumbers> .. </form>Igual que los validadores de entrada, ¿verdad? Recién ahora aplicado al formulario.
¿Recuerdas el componente ShowErrors? Lo implementamos para que funcione con AbstractControlDirective, lo que significa que también podríamos reutilizarlo para mostrar todos los errores asociados directamente con este formulario. Tenga en cuenta que en este punto las únicas reglas de validación asociadas directamente con el formulario son el Country-city y los Telephone numbers (los otros validadores están asociados a los controles específicos del formulario). Para imprimir todos los errores de formulario, simplemente haga lo siguiente:
<form #myForm="ngForm" countryCity telephoneNumbers > <show-errors [control]="myForm"></show-errors> .. </form> Lo último que queda es la validación de un nombre único. Esto es un poco diferente; para verificar si el nombre es único, lo más probable es que se necesite una llamada al back-end para verificar todos los nombres existentes. Esto se clasifica como una operación asíncrona. Para este propósito, podemos reutilizar la técnica anterior para validadores personalizados, simplemente haga que la validate devuelva un objeto que se resolverá en el futuro (promesa o un observable). En nuestro caso, usaremos una promesa:
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); }); } } Esperamos 1 segundo y luego devolvemos un resultado. Similar a los validadores de sincronización, si la promesa se resuelve con null , eso significa que la validación pasó; si la promesa se resuelve con algo más, la validación falló. También tenga en cuenta que ahora este validador está registrado en otro proveedor múltiple, NG_ASYNC_VALIDATORS . Una propiedad útil de los formularios con respecto a los validadores asíncronos es la propiedad pending . Se puede usar así:
<button [disabled]="myForm.pending">Register</button>Deshabilitará el botón hasta que se resuelvan los validadores asíncronos.
Aquí hay un Plunker que contiene el AppComponent completo, el componente ShowErrors y todos los validadores.
Con estos ejemplos, hemos cubierto la mayoría de los casos para trabajar con formularios controlados por plantillas. Hemos demostrado que los formularios controlados por plantillas son realmente similares a los formularios en AngularJS (será muy fácil de migrar para los desarrolladores de AngularJS). Con este tipo de formulario, es bastante fácil integrar formularios Angular 4 con una programación mínima, principalmente con manipulaciones en la plantilla HTML.
Formas reactivas
Los formularios reactivos también se conocían como formularios "basados en modelos", pero me gusta llamarlos formularios "programáticos", y pronto verá por qué. Los formularios reactivos son un nuevo enfoque hacia el soporte de formularios Angular 4, por lo que, a diferencia de los basados en plantillas, los desarrolladores de AngularJS no estarán familiarizados con este tipo.
Podemos comenzar ahora, ¿recuerdas que los formularios basados en plantillas tenían un módulo especial? Pues bien, los formularios reactivos también tienen su propio módulo, llamado ReactiveFormsModule y hay que importarlo para activar este tipo de formularios.
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 {}Además, no olvide arrancar la aplicación.
Podemos comenzar con el mismo AppComponent y plantilla que en la sección anterior.
En este punto, si FormsModule no está importado (y asegúrese de que no lo esté), solo tenemos un elemento de formulario HTML normal con un par de controles de formulario, sin magia angular aquí.
Llegamos al punto en el que notará por qué me gusta llamar a este enfoque "programático". Para habilitar los formularios de Angular 4, debemos declarar el objeto FormGroup manualmente y completarlo con controles como este:

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); } } Los métodos printForm y register son los mismos de los ejemplos anteriores y se usarán en los próximos pasos. Los tipos de clave utilizados aquí son FormGroup, FormControl y FormArray. Estos tres tipos son todo lo que necesitamos para crear un FormGroup válido. El FormGroup es fácil; es un simple contenedor de controles. El FormControl también es fácil; es cualquier control (por ejemplo, entrada). Y por último, FormArray es la pieza del rompecabezas que nos faltaba en el enfoque basado en plantillas. FormArray permite mantener un grupo de controles sin especificar una clave concreta para cada control, básicamente una matriz de controles (parece lo perfecto para los números de teléfono, ¿no?).
Cuando construya cualquiera de estos tres tipos, recuerde esta regla de los 3. El constructor de cada tipo recibe tres argumentos: value , validador o lista de validadores y validador asíncrono o lista de validadores asíncronos, definidos en el código:
constructor(value: any, validator?: ValidatorFn | ValidatorFn[], asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[]); Para FormGroup, el value es un objeto donde cada clave representa el nombre de un control y el valor es el control mismo.
Para FormArray, el value es una matriz de controles.
Para FormControl, el value es el valor inicial o el estado inicial (objeto que contiene un value y una propiedad disabled ) del control.
Hemos creado el objeto FormGroup, pero la plantilla aún no reconoce este objeto. La vinculación entre FormGroup en el componente y la plantilla se realiza con cuatro directivas: formGroup , formControlName , formGroupName y formArrayName , usadas así:
<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>Ahora que tenemos FormArray, puede ver que podemos usar esa estructura para representar todos los números de teléfono.
Y ahora para agregar el soporte para agregar y eliminar números de teléfono (en el componente):
remove(i: number) { (<FormArray>this.myForm.get('phoneNumbers')).removeAt(i); } add() { (<FormArray>this.myForm.get('phoneNumbers')).push(new FormControl('')); }Ahora, tenemos una forma reactiva de Angular 4 en pleno funcionamiento. Observe la diferencia con los formularios controlados por plantillas donde el FormGroup se "creó en la plantilla" (al escanear la estructura de la plantilla) y se pasó al componente, en los formularios reactivos es al revés, el FormGroup completo se crea en el componente, luego “pasado a la plantilla” y vinculado con los controles correspondientes. Pero, nuevamente tenemos el mismo problema con la validación, un problema que se resolverá en la siguiente sección.
Validación
Cuando se trata de validación, los formularios reactivos son mucho más flexibles que los formularios basados en plantillas. Sin cambios adicionales, podemos reutilizar los mismos validadores que se implementaron anteriormente (para los basados en plantillas). Entonces, al agregar las directivas del validador, podemos activar la misma validación:
<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>Tenga en cuenta que ahora no tenemos la directiva NgModel para pasar al componente ShowErrors, pero el FormGroup completo ya está construido y podemos pasar el AbstractControl correcto para recuperar los errores.
Aquí hay un Plunker de trabajo completo con este tipo de validación para formularios reactivos.
Pero no sería divertido si solo reutilizáramos los validadores, ¿verdad? Veremos cómo especificar los validadores al crear el grupo de formularios.
¿Recuerda la regla de la "regla de los 3" que mencionamos sobre el constructor para FormGroup, FormControl y FormArray? Sí, dijimos que el constructor puede recibir funciones de validación. Entonces, probemos ese enfoque.
Primero, necesitamos extraer las funciones de validate de todos los validadores en una clase exponiéndolas como métodos estáticos:
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]) );¿Ver? 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.
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?
Una vez que sepa eso, piense qué estamos haciendo si agregamos el estado de los formularios. Así es, los datos se empujan hacia arriba en el árbol de componentes. Incluso cuando se trabaja con formularios de un solo nivel, la integración de los controles de formulario ( ngModel ) y el formulario en sí no es tan agradable. Activan un ciclo adicional de detección de cambios al registrar o actualizar el valor de un control (se hace usando una promesa resuelta, pero manteniéndola en secreto). ¿Por qué se necesita una ronda adicional? Bueno, por la misma razón, los datos fluyen hacia arriba, desde el control hasta el formulario. Pero, tal vez, a veces, anidar formularios en varios componentes es una característica necesaria y debemos pensar en una solución para respaldar este requisito.
Con lo que sabemos hasta ahora, la primera idea que me viene a la mente es usar formularios reactivos, crear el árbol de formulario completo en algún componente raíz y luego pasar los formularios secundarios a los componentes secundarios como entradas. De esta manera, ha acoplado estrechamente el componente principal con los componentes secundarios y ha desordenado la lógica comercial del componente raíz con el manejo de la creación de todos los formularios secundarios. Vamos, somos profesionales, estoy seguro de que podemos encontrar una manera de crear componentes totalmente aislados con formularios y proporcionar una forma de que el formulario solo propague el estado a quien sea el padre.
Dicho todo esto, aquí hay una directiva que permite anidar formularios Angular 4 (implementada porque era necesaria para un proyecto):
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()); } } El ejemplo del siguiente GIF muestra un componente main que contiene form-1 y, dentro de ese formulario, hay otro componente anidado, component-2 . component-2 contiene form-2 , que ha anidado form-2.1 , form-2.2 , y un componente ( component-3 ) que tiene un árbol de una forma reactiva y un componente ( component-4 ) que contiene una forma que está aislado de todas las demás formas. Bastante desordenado, lo sé, pero quería hacer un escenario bastante complejo para mostrar la funcionalidad de esta directiva.
El ejemplo está implementado en este Plunker.
Las características que ofrece son:
Habilita el anidamiento agregando la directiva nestableForm a los elementos: formulario, ngForm, [ngForm], [formGroup]
Funciona con formularios reactivos y basados en plantillas.
Permite construir un árbol de formularios que abarca múltiples componentes
Aísla un subárbol de formularios con rootNestableForm=”true” (no se registrará en el padre nestableForm)
Esta directiva permite que un formulario en un componente secundario se registre en el primer nestableForm principal, independientemente de si el formulario principal se declara en el mismo componente o no. Vamos a entrar en los detalles de la implementación.
En primer lugar, echemos un vistazo al constructor. El primer argumento es:
@SkipSelf() @Optional() private parentForm: NestableFormDirectiveEsto busca el primer padre NestableFormDirective. @SkipSelf, para no coincidir consigo mismo, y @Optional porque es posible que no encuentre un padre, en el caso de la forma raíz. Ahora tenemos una referencia al formulario padre anidable.
El segundo argumento es:
private injector: Injector El inyector se utiliza para recuperar el provider actual de FormGroup (plantilla o reactivo).
Y el último argumento es:
@Attribute('rootNestableForm') private isRootpara obtener el valor que determina si este formulario está aislado del árbol de formularios.
A continuación, en ngInit como una acción pospuesta (¿recuerda el flujo de datos inverso?), se resuelve el FormGroup actual, se registra un nuevo control FormArray llamado CHILD_FORMS en este FormGroup (donde se registrarán los formularios secundarios) y como última acción, el FormGroup actual está registrado como un elemento secundario del formulario principal anidable.
La acción ngOnDestroy se ejecuta cuando se destruye el formulario. Al destruir, nuevamente como una acción pospuesta, el formulario actual se elimina del padre (desregistro).
La directiva para formularios anidables se puede personalizar aún más para una necesidad específica, tal vez eliminar el soporte para formularios reactivos, registrar cada formulario secundario con un nombre específico (no en una matriz CHILD_FORMS), etc. Esta implementación de la directiva nestableForm cumplió con los requisitos del proyecto y se presenta aquí como tal. Cubre algunos casos básicos como agregar un nuevo formulario o eliminar un formulario existente dinámicamente (*ngIf) y propagar el estado del formulario al padre. Básicamente, esto se reduce a operaciones que se pueden resolver dentro de un ciclo de detección de cambios (con aplazamiento o no).
Si desea un escenario más avanzado, como agregar una validación condicional a alguna entrada (por ejemplo, [requerido] = "alguna condición") que requeriría 2 rondas de detección de cambios, no funcionará debido a la regla de "resolución de ciclo de detección única". impuesta por Angular 4.
De todos modos, si planea usar esta directiva o implementar alguna otra solución, tenga mucho cuidado con las cosas que se mencionaron relacionadas con la detección de cambios. En este punto, así es como se implementa Angular 4. Podría cambiar en el futuro; no podemos saberlo. La configuración actual y la restricción aplicada en Angular 4 que se mencionó en este artículo pueden ser un inconveniente o un beneficio. Queda por verse.
Formularios simplificados con Angular 4
Como puede ver, el equipo de Angular ha hecho un muy buen trabajo al proporcionar muchas funcionalidades relacionadas con los formularios. Espero que esta publicación sirva como una guía completa para trabajar con los diferentes tipos de formularios en Angular 4, y que también brinde información sobre algunos conceptos más avanzados como el anidamiento de formularios y el proceso de detección de cambios.
A pesar de todas las diferentes publicaciones relacionadas con los formularios de Angular 4 (o cualquier otro tema de Angular 4), en mi opinión, el mejor punto de partida es la documentación oficial de Angular 4. Además, los chicos de Angular tienen buena documentación en su código. Muchas veces, he encontrado una solución simplemente mirando su código fuente y la documentación allí, sin buscar en Google ni nada. Sobre el anidamiento de formularios, discutido en la última sección, creo que cualquier desarrollador de AngularJS que comience a aprender Angular 4 se topará con este problema en algún momento, que fue mi inspiración para escribir esta publicación.
Como también hemos visto, hay dos tipos de formularios, y no existe una regla estricta de que no se puedan usar juntos. Es bueno mantener el código base limpio y consistente, pero a veces, algo se puede hacer más fácilmente con formularios controlados por plantillas y, a veces, es al revés. Por lo tanto, si no le importan los tamaños de paquete un poco más grandes, le sugiero que use lo que considere más apropiado caso por caso. Simplemente no los mezcle dentro del mismo componente porque probablemente generará cierta confusión.
Plunkers utilizados en esta publicación
Formularios basados en plantillas
Formularios reactivos, validadores de plantillas
Formularios reactivos, validadores de código
Formularios reactivos, generador de formularios
Formularios controlados por plantillas, anidados dentro del mismo componente
Formularios reactivos, anidados dentro del mismo componente
Formularios anidados mediante árbol de componentes
