💻 Exemplos: Capítulo 13: Reactive Forms, FormBuilder, FormArray e Validações Customizadas
Especialização em Front-End com Angular • Demonstrações Práticas Compiláveis
💻 Código de Exemplo 13 — Formulários Reativos Tipados com FormBuilder, FormArray e Validador Customizado
Este exemplo apresenta a criação de formulários corporativos rigorosamente tipados com Reactive Forms no Angular 18+, utilizando NonNullableFormBuilder, campos dinâmicos com FormArray e uma função de validação customizada de domínio.
// src/app/components/cluster-provision-form.component.ts
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
ReactiveFormsModule,
NonNullableFormBuilder,
Validators,
AbstractControl,
ValidationErrors
} from '@angular/forms';
// 1. Validador Customizado Síncrono de Domínio
export function corporateDomainValidator(control: AbstractControl): ValidationErrors | null {
const email = String(control.value || '').toLowerCase();
if (!email) return null;
if (!email.endsWith('@empresa.com.br')) {
return { invalidCorporateDomain: { expectedDomain: '@empresa.com.br', actual: email } };
}
return null;
}
@Component({
selector: 'app-cluster-provision-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
template: `
<div class="max-w-xl mx-auto p-6 bg-white border border-slate-200 rounded-2xl shadow-sm space-y-6">
<div class="border-b pb-4">
<h2 class="text-xl font-bold text-slate-800">📋 Provisionamento de Cluster Seguro</h2>
<p class="text-xs text-slate-500">NonNullableFormBuilder, FormArray Dinâmico e Validador Customizado</p>
</div>
<!-- Banner de Sucesso pós-submissão -->
@if (submissionSuccess()) {
<div class="p-4 bg-emerald-50 border border-emerald-200 text-emerald-800 rounded-xl text-xs font-semibold">
✅ Cluster provisionado com sucesso! Instruções enviadas ao responsável técnico.
</div>
}
<form [formGroup]="provisionForm" (ngSubmit)="onSubmit()" class="space-y-4">
<!-- Campo: Nome do Cluster -->
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">Nome do Cluster:</label>
<input
type="text"
formControlName="clusterName"
placeholder="Ex: prod-k8s-payment-cluster"
class="w-full border px-3 py-2 rounded-lg text-sm outline-none focus:ring-2 focus:ring-red-500"
[class.border-red-400]="isFieldInvalid('clusterName')"
/>
@if (isFieldInvalid('clusterName')) {
<p class="text-xs text-red-600 mt-1 font-medium">
O nome do cluster é obrigatório e deve ter ao menos 4 caracteres.
</p>
}
</div>
<!-- Campo: E-mail com Validador Customizado -->
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">E-mail do Engenheiro Responsável:</label>
<input
type="email"
formControlName="adminEmail"
placeholder="engenheiro@empresa.com.br"
class="w-full border px-3 py-2 rounded-lg text-sm outline-none focus:ring-2 focus:ring-red-500"
[class.border-red-400]="isFieldInvalid('adminEmail')"
/>
@if (isFieldInvalid('adminEmail')) {
<p class="text-xs text-red-600 mt-1 font-medium">
@if (provisionForm.controls.adminEmail.errors?.['required']) { E-mail é obrigatório. }
@if (provisionForm.controls.adminEmail.errors?.['email']) { Formato de e-mail inválido. }
@if (provisionForm.controls.adminEmail.errors?.['invalidCorporateDomain']) {
Apenas e-mails do domínio @empresa.com.br são aceitos.
}
</p>
}
</div>
<!-- FormArray Dinâmico: Nós do Cluster -->
<div class="space-y-3 pt-2 border-t">
<div class="flex justify-between items-center">
<span class="text-xs font-bold text-slate-700 uppercase">Tags de Nós (FormArray):</span>
<button
type="button"
(click)="addNodeTag()"
class="text-xs text-indigo-600 hover:underline font-semibold"
>
+ Adicionar Tag de Nó
</button>
</div>
<div formArrayName="nodeTags" class="space-y-2">
@for (ctrl of nodeTags.controls; track $index) {
<div class="flex gap-2 items-center">
<input
type="text"
[formControlName]="$index"
placeholder="Tag do nó (ex: role=worker-01)"
class="flex-1 border px-3 py-1.5 rounded-lg text-xs"
/>
<button
type="button"
(click)="removeNodeTag($index)"
class="text-red-500 hover:text-red-700 text-xs px-2 py-1 font-bold"
title="Remover Tag"
>
✕
</button>
</div>
}
</div>
</div>
<!-- Botão de Envio com Bloqueio de Invalidade -->
<button
type="submit"
[disabled]="provisionForm.invalid || isSubmitting()"
class="w-full py-2.5 bg-red-600 hover:bg-red-700 text-white rounded-xl text-xs font-bold transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
@if (isSubmitting()) {
<span class="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
<span>Provisionando Infraestrutura...</span>
} @else {
<span>Confirmar Provisionamento</span>
}
</button>
</form>
</div>
`
})
export class ClusterProvisionFormComponent {
private fb = inject(NonNullableFormBuilder);
isSubmitting = signal<boolean>(false);
submissionSuccess = signal<boolean>(false);
// 2. Formulário Tipado Estritamente (NonNullable garante que reset retorne ao initialValue)
provisionForm = this.fb.group({
clusterName: ['', [Validators.required, Validators.minLength(4)]],
adminEmail: ['', [Validators.required, Validators.email, corporateDomainValidator]],
nodeTags: this.fb.array([
this.fb.control('role=ingress-controller', Validators.required),
this.fb.control('zone=sa-east-1a', Validators.required)
])
});
get nodeTags() {
return this.provisionForm.controls.nodeTags;
}
isFieldInvalid(fieldName: 'clusterName' | 'adminEmail'): boolean {
const ctrl = this.provisionForm.get(fieldName);
return !!(ctrl && ctrl.invalid && (ctrl.dirty || ctrl.touched));
}
addNodeTag(): void {
this.nodeTags.push(this.fb.control('', Validators.required));
}
removeNodeTag(index: number): void {
if (this.nodeTags.length > 1) {
this.nodeTags.removeAt(index);
}
}
async onSubmit(): Promise<void> {
if (this.provisionForm.invalid) {
this.provisionForm.markAllAsTouched();
return;
}
this.isSubmitting.set(true);
this.submissionSuccess.set(false);
// Simulação de chamada de API backend
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log('[Form Submitted] Payload recebido:', this.provisionForm.getRawValue());
this.isSubmitting.set(false);
this.submissionSuccess.set(true);
this.provisionForm.reset();
}
}
🖥️ Saída / Comportamento Esperado
Renderização Inicial:
Formulário inicializado com 2 tags no FormArray:
Tag 1: role=ingress-controller
Tag 2: zone=sa-east-1a
Botão "Confirmar Provisionamento" desabilitado (clusterName e adminEmail estão vazios).
Validação do E-mail Corporativo:
Digitar adminEmail: "marcos@gmail.com" e sair do campo (blur):
Borda vermelha ativada.
Mensagem: "Apenas e-mails do domínio @empresa.com.br são aceitos."
Correção:
Digitar "marcos@empresa.com.br" -> Erro desaparece.
Nome do cluster: "prod-cluster-01" -> Válido.
Botão habilitado.
Submissão:
Ao clicar, spinner de carregamento ativado.
Após 1000ms:
Banner verde: "✅ Cluster provisionado com sucesso!".
reset() restaura o formulário com dados padrão seguros.
🔍 Destaques da Implementação
NonNullableFormBuilder: Garante que valores de controles nunca sejam convertidos paranullao chamarreset().FormArrayDinâmico: Permite adicionar ou remover nós sob demanda respeitando a validação de cada item individual.- Validador Customizado Reutilizável: A função
corporateDomainValidatorpode ser testada em isolamento com Vitest sem instanciar componentes.