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 &#64;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

  1. NonNullableFormBuilder: Garante que valores de controles nunca sejam convertidos para null ao chamar reset().
  2. FormArray Dinâmico: Permite adicionar ou remover nós sob demanda respeitando a validação de cada item individual.
  3. Validador Customizado Reutilizável: A função corporateDomainValidator pode ser testada em isolamento com Vitest sem instanciar componentes.