Imagine que seu líder tenha lhe pedido para criar um sistema de agendamento, mas que como principal requisito, nele deve ser possível gerenciar de forma fácil as agendas e seus espaços, numa única tela, através de um formulário dinâmico.
Considerando a relação entre os models/entidades demonstrada abaixo, e sabendo que cada formulário está relacionado com um único model, qual a melhor alternativa para resolver este problema?

Para solucionar essa limitação, utilizaremos um recurso nativo do Rails, muito popular, chamado Nested Form. Assim, utilizando os relacionamentos existentes de nossa aplicação permitiremos que o formulário processe informações de mais de um model simultaneamente.
Configurando os Models
Considerando que já criamos nossas tabelas e seus respectivos models, é importante verificar se os relacionamentos estão devidamente declarados.
class Agenda < ApplicationRecord
belongs_to :professional
has_many :agenda_slots
end
Model de Agenda
class AgendaSlot < ApplicationRecord
belongs_to :agenda
end
Model de AgendaSlot (espaços da agenda)
Na sequência, precisamos aplicar uma configuração que autoriza o uso de nested forms para o relacionamento em questão. Para isso, é necessário apenas adicionar: accepts_nested_attributes_for :agenda_slots
ao model de Agenda
.
Feito isso, nosso próximo passo será partir para a criação do formulário.
Criando o formulário
Se você tiver utilizado o scaffold para gerar o model de Agenda
, deve ter chegado a um resultado similar ao representado abaixo:
<%= simple_form_for(@agenda) do |f| %>
<%= f.error_notification %>
<%= f.error_notification message: f.object.errors[:base].to_sentence if f.object.errors[:base].present? %>
<div class="form-inputs">
<%= f.association :professional %>
<%= f.input :place_name %>
</div>
<div class="form-actions">
<%= f.button :submit %>
</div>
<% end %>
Exemplo do app/views/agendas/_form.html.erb gerado via scaffold
Como já inserimos em nosso model a configuração que habilita o uso de nested forms para nosso formulário, vamos agora adicionar uma nova nova seção.
<%= simple_form_for(@agenda) do |f| %>
...
<div>
<%= f.simple_fields_for :agenda_slots do |slot_fields| %>
<%= render "slot_form", f: slot_fields %>
<% end %>
</div>
...
<% end %>
Adiciona declaração do simple_fields_for ao formulário de agenda
<div class="row nested-form-wrapper">
<div class="col">
<%= f.input :date, html5: true %>
</div>
<div class="col">
<%= f.input :start, html5: true %>
</div>
<div class="col">
<%= f.input :finish, html5: true %>
</div>
</div>
app/views/agendas/_slot_form.html.erb
Feito isso, nosso formulário está pronto para exibir os campos do AgendaSlot (ou quase! hehe).

Para testar, vamos adicionar um elemento de AgendaSlot, fazendo um build
diretamente no controller, para que os campos existam antes de renderizarmos a tela do formulário em nosso navegador.
class AgendasController < ApplicationController
#...
def new
@agenda = Agenda.new
@agenda.agenda_slots.build
end
end
Feito isso, é necessário liberar os parâmetros permitidos, para que o controller repasse os atributos ao model. Sendo assim é necessário alterar o método agenda_params
do controller.
class AgendasController < ApplicationController
# ...
private
def agenda_params
params.require(:agenda)
.permit(
:professional_id, :place_name,
agenda_slots_attributes: [:id, :date, :start, :finish, :_destroy]
)
end
end
Você deve ter percebido que adicionamos, além dos atributos presentes no formulário, dois itens extras: id e _destroy. Ambos serão utilizados, respectivamente, para permitir que o Rails identifique itens salvos anteriormente e permitir marca-los para remoção.
Por mais que nosso cadastro funcione, sendo que cada build resultará em um slot. Não resolve nosso problema!
Para aplicar adições e remoções a nosso formulário, após sua renderização, será necessário que o navegador consiga realizar tais modificações na página já renderizada.
E sem Javascript isso não será possível...

Tornando-o dinâmico
Existem duas formas que podemos utilizar para tornar nosso formulário dinâmico, usando Javascript:
- criando um código do zero - o chamado Vanilla - com Javascript "puro" e onde precisaríamos nos preocupar com cada detalhe da adição e remoção
- ou aplicando alguma estrutura que abstraia a complexidade que vamos ter que gerenciar. ✅
Através da 2ª abordagem, vamos nos poupar de escrever dezenas de linhas de código javascript. Mas que mágica é essa?
Vamos utilizar o próprio Stimulus.js que vem integrado ao Rails e que com certeza irá facilitar muito nosso trabalho.

Utilizando o Stimulus, aplicaremos um componente pronto para abstrair o processo de adição e remoção de itens do Nested Form, que será sempre igual em formulários aninhados. Então, precisamos adicionar a dependência ao projeto.
Interrompa seu servidor web e execute o seguinte comando:
yarn add stimulus-rails-nested-form
Feito isso, iremos alterar o arquivo app/javascript/controllers/index.js
de forma que fique similar ao trecho a seguir:
// ...
import { application } from "./application"
import NestedForm from 'stimulus-rails-nested-form'
// ...
application.register('nested-form', NestedForm)
// ...
Então, inicie novamente seu servidor com o comando bin/dev
.
Precisamos ajustar nosso formulário para que ele reconheça os processos de adição e remoção de itens. Mas antes devemos entender melhor como esse componente funciona. Seu funcionamento consiste em 3 partes:
- Template
- Adição via Cópia
- Remoção
Template
É necessário que o stimulus-rails-nested-form
tenha uma referência de como deve cada item do AgendaSlot em nosso formulário, para isso adicione uma seção de template ao formulário de agenda, ajustando o div destinado a nosso nested, conforme apresentado abaixo:
<%= simple_form_for(@agenda) do |f| %>
...
<div data-controller="nested-form" data-nested-form-wrapper-selector-value=".nested-form-wrapper">
<template data-nested-form-target="template">
<%= f.simple_fields_for :agenda_slots, AgendaSlot.new, child_index: 'NEW_RECORD' do |slot_fields| %>
<%= render "slot_form", f: slot_fields %>
<% end %>
</template>
<%= f.simple_fields_for :agenda_slots do |slot_fields| %>
<%= render "slot_form", f: slot_fields %>
<% end %>
</div>
...
<% end %>
Feito isso, é necessário habilitar a adição de novos itens.
Adição via Cópia
Seguindo o que padrão do componente, é necessário adicionarmos um ponto de referência, onde os novos slots serão inseridos, e um botão que dispare esse evento.
<%= simple_form_for(@agenda) do |f| %>
...
<div data-controller="nested-form" data-nested-form-wrapper-selector-value=".nested-form-wrapper">
<template data-nested-form-target="template">
<%= f.simple_fields_for :agenda_slots, AgendaSlot.new, child_index: 'NEW_RECORD' do |slot_fields| %>
<%= render "slot_form", f: slot_fields %>
<% end %>
</template>
<%= f.simple_fields_for :agenda_slots do |slot_fields| %>
<%= render "slot_form", f: slot_fields %>
<% end %>
<div data-nested-form-target="target"></div>
<button type="button" class="btn btn-secondary" data-action="nested-form#add">
Novo Horário
</button>
</div>
...
<% end %>
Feito isso, nosso formulário está muito mais funcional.

Agora os comandos @agenda.agenda_slots.build
não são mais necessários, portando você pode remove-los do controller.
Remoção
Uma vez que adição de novos itens está totalmente funcional, precisamos agora permitir a remoção de itens já armazenados no banco de dados. Para isso, vamos precisar alterar nosso partial de slot.
<div class="row nested-form-wrapper" data-new-record="<%= f.object.new_record? %>">
<div class="col">
<%= f.input :date, html5: true %>
</div>
<div class="col">
<%= f.input :start, html5: true %>
</div>
<div class="col">
<%= f.input :finish, html5: true %>
</div>
<%= f.hidden_field :_destroy %>
<div class="col">
<button type="button" class="btn btn-danger mt-4" data-action="nested-form#remove">
Remover
</button>
</div>
</div>
Para que a remoção funcione adequadamente 4 coisas são essenciais:
- O Botão de Remover, devidamente vinculado a ação de remoção
- Um indicativo se o registro em questão é novo (não persistido) informado pelo atributo
data-new-record
- Um campo oculto denominado
_destroy
que armazena uma flag (true/false - 0/1) se o registro deve ser removido. - A opção
allow_destroy: true
deve ser adicionada a declaração do nested no model de Agenda.
Com isso, a remoção de novos itens passará a funcionar.
Para tornar o formulário mais completo
Um excelente plus para nosso cadastro é que itens que sejam adicionados, mas não estejam preenchidos, possam ser ignorados. Assim não corremos o risco do formulário ficar inválido indevidamente ou inserirmos "sujeira" de dados em nosso banco.
Para isso, basta apenas alterar a declaração que autoriza o nested form, no model de Agenda.
class Agenda < ApplicationRecord
belongs_to :professional
has_many :agenda_slots, -> { order(:date, :start) }
accepts_nested_attributes_for :agenda_slots, reject_if: :all_blank, allow_destroy: true
end
O parâmetro reject_if: :all_blank
, como sugere, ignora o registro caso todos os campos estejam vazios.
Aproveitamos, e definimos uma ordenação para nosso relacionamento. Assim, todos os registros passarão a serem exibidos em ordem cronológica, após salvos no banco de dados.
Até a próxima!
Referências

