Como criar Nested Form dinâmico com Stimulus.js

Ruby on Rails
25 de agosto de 2023

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?

Representação relacional das entidades do projeto

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.

💡
A declaração do accepts_nested_attributes_for deve ser condizente com o nome do relacionamento existente no model em questão. Em nosso exemplo, dentro do model de Agenda há um relacionamento do tipo has_many denominado agenda_slots. Sendo assim, a declaração segue essa convenção.

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

💡
A opção f.association apresentada acima é um recurso da gem simple_form, que facilita a criação de campos baseados em relacionamentos. Por convenção, ele utilizado o ID como valor e o método/atributo name como texto de exibição. Para mais informações consulte a documentação da gem.

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).

O que está faltando? 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
💡
Todo Nested Form segue uma convenção, assim como as demais estruturas do Rails. Como nosso relacionamento permitido é chamado agenda_slots, logo os atributos relacionados estão presentes como agenda_slots_attributes (sempre no plural).

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...

Castigo do monstro, sim ele voltou, aquele que é o mais temido de todas as edições do bbb | Ivy meme
O mais temido de todas as edições... 😭

Tornando-o dinâmico

Existem duas formas que podemos utilizar para tornar nosso formulário dinâmico, usando Javascript:

  1. 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
  2. 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:

  1. Template
  2. Adição via Cópia
  3. 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 %>
💡
O elemento <template> é uma estrutura que permite encapsular um conteúdo do lado do cliente que não é renderizado quando a página é carregada, mas que pode ser utilizado posteriormente usando JavaScript.

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

GitHub - invenio-br/live14-agenda-facil
Contribute to invenio-br/live14-agenda-facil development by creating an account on GitHub.
Stimulus Components
Simple and powerful Stimulus JS library for common JavaScript behavior.
GitHub - heartcombo/simple_form: Forms made easy for Rails! It’s tied to a simple DSL, with no opinion on markup.
Forms made easy for Rails! It&#39;s tied to a simple DSL, with no opinion on markup. - GitHub - heartcombo/simple_form: Forms made easy for Rails! It&#39;s tied to a simple DSL, with no opinion on…
Stimulus: A modest JavaScript framework for the HTML you already have.
Stimulus is a JavaScript framework with modest ambitions. It doesn’t seek to take over your entire front-end—in fact, it’s not concerned with rendering HTML at all. Instead, it’s designed to augment your HTML with just enough behavior to make it shine.
<template> - HTML: Linguagem de Marcação de Hipertexto | MDN
O elemento HTML <template> é um mecanismo para encapsular um conteúdo do lado do cliente que não é renderizado quando a página é carregada, mas que pode ser instanciado posteriormente em tempo de execução usando JavaScript.
8 min. de leitura
Top