Como construir um formulário dinâmico - Angular

Introdução

Trabalhar com formulários estáticos com certeza é algo bem comum na vida de um desenvolvedor. Dois bons exemplos são os formulários de login e cadastro. Ambos não mudam com tanta frequência, ou seja, dificilmente você irá acrescentar, editar ou remover campos. Desta forma deixá-lo (formulário) fixo no código faz todo sentido.

Em outros cenários onde a frequência de atualização dos formulários é grande ou seus campos são condicionados a outros fatores, faz mais sentido partir para uma solução dinâmica. Podemos por exemplo, ler uma lista de parâmetros de uma API e criar dinamicamente um formulário ou podemos mostrar e ocultar alguns campos do formulário dependendo do perfil do usuário. Há diversas aplicações, mas neste texto quero mostrar como consultar uma API e construir um formulário dinâmico utilizando o FormBuilder.array do Angular.

Objetivo

Para deixar a leitura mais clara, o objetivo será criar um formulário com:

  • um campo obrigatório: nome
  • um campo obrigatório para cada pergunta com duas opções de seleção: Sim ou Não. As perguntas serão retornadas através de uma service de forma dinâmica, ou seja, se a lista de perguntas for alterada, o formulário deverá refletir a alteração.

Criando o formulário com um campo

Vamos começar do mais simples, um único FormGroup com um único campo:

import { Component } from '@angular/core';
import {
  FormBuilder,
  FormGroup,
  Validators,
} from '@angular/forms';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
})
export class AppComponent {
  form: FormGroup;

  constructor(private fb: FormBuilder) {
    this.form = this.fb.group({
      nome: [null, [Validators.required]],
    });
  }

  postarDados() {
    const dados = this.form.value;
    console.log(dados);
  }
}
app.component.ts
<form [formGroup]="form" (ngSubmit)="postarDados()">
  <div>
    <input
      formControlName="nome"
      placeholder="Nome..."
      type="text" />
    <small *ngIf="form.get('nome')?.errors">
      Campo obrigatório
    </small>
  </div>
  <div>
    <input type="submit" value="Postar" />
  </div>
</form>
app.component.html
Resultado

Até aqui nenhuma novidade e tudo está bem simples. Porém, caso tenha dúvidas sugiro a leitura deste link antes de continuar com este texto.

Criando a service para retornar as perguntas do formulário

Antes de voltar ao formulário, vamos contextualizar como as perguntas que constarão no formulário serão consultadas.

Para simular o retorno de uma API criei no mocky.io a seguinte massa de dados:

[
  {
    "_id": 1,
    "pergunta": "Pergunta 1"
  },
  {
    "_id": 2,
    "pergunta": "Pergunta 2"
  },
  {
    "_id": 3,
    "pergunta": "Pergunta 3"
  }
]

A service para consultar este endpoint ficará da seguinte forma:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { Pergunta } from './pergunta.model';

@Injectable({ providedIn: 'root' })
export class PerguntaService {
  // ****************************************
  // Lembre-se de importar o HttpClientModule
  // para utilizar o HttpClient.
  // Neste exemplo fiz o import no arquivo
  // AppModule
  // ****************************************
  constructor(private http: HttpClient) {}

  consultarPerguntas(): Observable<Pergunta[]> {
    const url =
      'https://run.mocky.io/v3/5ede2fdb-32e2-4c65-a068-3906b61bc87d';

    return this.http.get<Pergunta[]>(url);
  }
}
pergunta.service.ts
export interface Pergunta {
  _id: number;
  pergunta: string;
}
pergunta.model.ts

Agora vamos injetar esta service no AppComponent:

constructor(
  private fb: FormBuilder,
  private perguntaService: PerguntaService
) {
  this.form = this.fb.group({
    nome: [null, [Validators.required]],
  });
  
  this.perguntaService
    .consultarPerguntas()
    .subscribe((perguntas) => {
      // TODO
  });
}
app.component.ts (trecho)

Utilizando o FormBuilder.array

Até este ponto temos um formulário com um único campo, nome, e um array de perguntas que o método consultarPerguntas() devolve.

Como o objetivo é validar se o usuário respondeu todas as perguntas, precisamos de alguma forma criar estas perguntas dentro do form, que por enquanto tem um único campo. Vamos por partes! primeiro pense de forma isolada, ou seja, somente em um FormGroup para cada objeto Pergunta. Então podemos escrever o seguinte:

criarFormGroupPergunta(pergunta: Pergunta): FormGroup {
  return this.fb.group({
    _id: [pergunta._id, [Validators.required]],
    resposta: [null, [Validators.required]],
  });
}
app.component.ts (trecho)

Como temos um array de perguntas, vamos ter um array de FormGroup e uma forma de organizar este conjunto de FormGroup dentro do form é através do FormBuilder.array:

this.form = this.fb.group({
  nome: [null, [Validators.required]],

  // Criando um FormArray:
  perguntas: this.fb.array([
    // Aqui vamos passar n FormGroup's
  ]),
});
app.component.ts (trecho)
A FormArray aggregates the values of each child FormControl into an array. It calculates its status by reducing the status values of its children. For example, if one of the controls in a FormArray is invalid, the entire array becomes invalid.

fonte: https://angular.io/api/forms/FormArray#description

Por fim, vamos colocar estes trechos de código dentro do AppComponent e efetuar um loop nas perguntas retornadas pelo PerguntaService:

import { Component } from '@angular/core';
import {
  FormArray,
  FormBuilder,
  FormGroup,
  Validators,
} from '@angular/forms';
import { PerguntaService } from './perguntas/pergunta-service';
import { Pergunta } from './perguntas/pergunta.model';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
})
export class AppComponent {
  form: FormGroup;

  constructor(
    private fb: FormBuilder,
    private perguntaService: PerguntaService
  ) {
    this.form = this.fb.group({
      nome: [null, [Validators.required]],
      perguntas: this.fb.array([
        // Aqui vamos passar n FormGroup's
      ]),
    });

    this.perguntaService
      .consultarPerguntas()
      .subscribe((perguntas) => {
        console.log(perguntas);
        // ***********************************
        // Faz um loop para criar um FormGroup
        // para cada pergunta.
        // Cada FormGroup é adicionado no
        // FormArray "perguntas"
        // ***********************************
        perguntas.forEach((pergunta) => {
          const formGroupPergunta =
            this.criarFormGroupPergunta(pergunta);

          const formPerguntas = this.form.get(
            'perguntas'
          ) as FormArray;

          formPerguntas.push(formGroupPergunta);
        });
      });
  }

  postarDados() {
    const dados = this.form.value;
    console.log(dados);
  }

  criarFormGroupPergunta(pergunta: Pergunta): FormGroup {
    return this.fb.group({
      _id: [pergunta._id, [Validators.required]],
      resposta: [null, [Validators.required]],
    });
  }
}
app.component.ts

Acrescentando apenas a linha abaixo no template já podemos ver que o form.value está um pouco diferente:

<!-- ...código ocultado... -->
{{ form.value | json }}
app.component.html (trecho)

Na tela irá aparecer o seguinte conteúdo:

Resultado

Renderizando o formulário de forma dinâmica

Agora precisamos fazer um loop em cima do perguntas do form para poder renderizar as perguntas e as opções de resposta:

<div
  *ngFor="let pergunta of form.get('perguntas')?.controls">
  TODO: Adicionar título da pergunta
  TODO: Adicionar opções de seleção
</div>
app.component.html (trecho)

Aqui temos um problema! Se você estiver utilizando o "strictTemplates": true (arquivo tsconfig.json) vai pegar o seguinte erro:

error TS2339: Property 'controls' does not exist on type 'AbstractControl'.

O motivo do erro é que o form.get(...) retorna um AbstractControl. Como o AbstractControl não tem um campo chamado controls o erro aparece na tela.

Passando o mouse em cima do ".get"

Porém, sabemos que o resultado do nosso this.form.get('perguntas') na realidade retorna um FormArray, então temos que "dizer" isto de forma explícita. Para fazer isto vou criar um simples getter:

get formPerguntas(): FormArray {
  return this.form.get('perguntas') as FormArray;
}
app.component.ts (trecho)

O getter será aproveitando para substituir os seguintes trechos:

// const formPerguntas = this.form.get(
//   'perguntas'
// ) as FormArray;

// formPerguntas.push(formGroupPergunta);
this.formPerguntas.push(formGroupPergunta);
app.component.ts (trecho)
<!-- ********************************************************
  <div
    *ngFor="let pergunta of form.get('perguntas')?.controls">
********************************************************* -->
<div
  *ngFor="let pergunta of formPerguntas.controls">
  TODO: Adicionar título da pergunta
  TODO: Adicionar opções de seleção
</div>
app.component.html (trecho)

Agora temos o resultado na tela:

Resultado

Adicionando as opções para cada pergunta

Como temos um FormGroup para cada pergunta, podemos escrever o seguinte:

<div *ngFor="let pergunta of formPerguntas.controls">
  TODO: Adicionar título da pergunta

  <ng-container [formGroup]="pergunta">
    <input
      type="radio"
      formControlName="resposta"
      [value]="false" />
    Não

    <input
      type="radio"
      formControlName="resposta"
      [value]="true" />
    Sim
  </ng-container>
</div>
app.component.html (trecho)

Novamente vamos pegar um outro erro com o "strictTemplates": true:

TS2740: Type 'AbstractControl' is missing the following properties from type 'FormGroup': controls, registerControl, addControl, removeControl, and 3 more.

O motivo do erro é que o formPerguntas.controls retorna um AbstractControl[] e o AbstractControl é uma classe abstrata que não implementa os métodos acima. Porém, a diretiva [formGroup] espera um objeto do tipo FormGroup que implementa os métodos abstratos do AbstractControl. Como temos certeza de que o formPerguntas.controls retorna um FormGroup[], podemos proceder de duas formas:

  1. efetuar um bypass nessa validação utilizando o $any()
  2. criar um outro getter que faça a tipagem explícita - seguindo o modelo do get formPerguntas(): FormArray.

Vou optar pelo $any() neste caso:

<form [formGroup]="form" (ngSubmit)="postarDados()">
  <!-- NOME -->
  <div>
    <input
      formControlName="nome"
      placeholder="Nome..."
      type="text" />
    <small *ngIf="form.get('nome')?.errors">
      Campo obrigatório
    </small>
  </div>
  <!-- PERGUNTAS -->
  <div *ngFor="let pergunta of formPerguntas.controls">
    TODO: Adicionar título da pergunta

    <ng-container [formGroup]="$any(pergunta)">
      <input
        type="radio"
        formControlName="resposta"
        [value]="false" />
      Não

      <input
        type="radio"
        formControlName="resposta"
        [value]="true" />
      Sim
    </ng-container>
  </div>
  <div>
    <input type="submit" value="Postar" />
  </div>
</form>
{{ form.value | json }}
app.component.html

Até este ponto já temos o formulário funcionando corretamente:

Ao selecionar o valor de uma opção para cada pergunta, o valor é refletido no valor do form

O que está falando é o texto de cada pergunta. Uma das alternativas para obter o conteúdo da pergunta é guardar o array retornado pelo PerguntaService em uma variável e criar uma lógica dentro do template utilizando o index do *ngFor. Seria algo do tipo:

<div
    *ngFor="
      let pergunta of formPerguntas.controls;
      let i = index
    ">
    {{ perguntas[i].pergunta }}
    <!-- ... -->
</div<

Vou optar por um outro caminho, que julgo ser mais simples para este cenário. Vou adicionar o título da pergunta no próprio FormGroup  e manter este novo FormControl desabilitado para que seu valor não seja incluído no form.value:

criarFormGroupPergunta(pergunta: Pergunta): FormGroup {
  return this.fb.group({
    _id: [pergunta._id, [Validators.required]],
    resposta: [null, [Validators.required]],
    pergunta: {
      value: pergunta.pergunta,
      disabled: true,
    },
  });
}
app.component.ts (trecho)

No template:

<div *ngFor="let pergunta of formPerguntas.controls">
  {{pergunta.get('pergunta')?.value}}
  
  <!-- ...(código ocultado)... -->
</div>
app.component.html (trecho)

O resultado final:

Resultado final - ao selecionar o valor de uma opção para cada pergunta, o valor é refletido no valor do form

Código final:

<form [formGroup]="form" (ngSubmit)="postarDados()">
  <!-- NOME -->
  <div>
    <input
      formControlName="nome"
      placeholder="Nome..."
      type="text" />
    <small *ngIf="form.get('nome')?.errors">
      Campo obrigatório
    </small>
  </div>

  <!-- PERGUNTAS -->
  <div *ngFor="let pergunta of formPerguntas.controls">
    {{pergunta.get('pergunta')?.value}}

    <ng-container [formGroup]="$any(pergunta)">
      <input
        type="radio"
        formControlName="resposta"
        [value]="false" />
      Não

      <input
        type="radio"
        formControlName="resposta"
        [value]="true" />
      Sim
    </ng-container>
  </div>
  <div>
    <input type="submit" value="Postar" />
  </div>
</form>
{{ form.value | json }}
app.component.html
import { Component } from '@angular/core';
import {
  FormArray,
  FormBuilder,
  FormGroup,
  Validators,
} from '@angular/forms';
import { PerguntaService } from './perguntas/pergunta-service';
import { Pergunta } from './perguntas/pergunta.model';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
})
export class AppComponent {
  form: FormGroup;

  get formPerguntas(): FormArray {
    return this.form.get('perguntas') as FormArray;
  }

  constructor(
    private fb: FormBuilder,
    private perguntaService: PerguntaService
  ) {
    this.form = this.fb.group({
      nome: [null, [Validators.required]],
      perguntas: this.fb.array([
        // Aqui vamos passar n FormGroup's
      ]),
    });

    this.perguntaService
      .consultarPerguntas()
      .subscribe((perguntas) => {
        console.log(perguntas);
        // ***********************************
        // Faz um loop para criar um FormGroup
        // para cada pergunta.
        // Cada FormGroup é adicionado no
        // FormArray "perguntas"
        // ***********************************
        perguntas.forEach((pergunta) => {
          const formGroupPergunta =
            this.criarFormGroupPergunta(pergunta);

          this.formPerguntas.push(formGroupPergunta);
        });
      });
  }

  postarDados() {
    const dados = this.form.value;
    console.log(dados);
  }

  criarFormGroupPergunta(pergunta: Pergunta): FormGroup {
    return this.fb.group({
      _id: [pergunta._id, [Validators.required]],
      resposta: [null, [Validators.required]],
      pergunta: {
        value: pergunta.pergunta,
        disabled: true,
      },
    });
  }
}
app.component.ts
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { AppJsonPipe } from './app-json.pipe';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';

@NgModule({
  declarations: [AppComponent, AppJsonPipe],
  imports: [
    AppRoutingModule,
    BrowserModule,
    HttpClientModule,
    ReactiveFormsModule,
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}
app.module.ts

Considerações

Neste texto apresentei um formulário dinâmico bem simples, mas que pode servir de base para casos mais complexos. Podemos destacar os principais pontos sobre esta leitura:

  • O strictTemplates realiza uma série de validações para ajudar o desenvolvedor a não cometer erros durante o tempo de compilação
  • O FormGroup.value ignora os FormControl marcados como disabled
  • E é claro, como construir um formulário de forma dinâmica.

Links interessantes: