segunda-feira, 6 de agosto de 2012

MultiModel forms, parte 3: Nested Forms

Este post é o terceiro de uma série de quatro artigos:
O código desta aplicação de exemplo está disponível neste repositório Github.

Nested Forms

Nos dois primeiros posts desta série, foi mostrado o poder do uso de Nested Models. O Rails fornece ainda helpers para a construção de views com este recurso. Usamos estes helpers para reescrever o arquivo app/views/people/_form.html.erb (criado via scaffolding, no primeiro post):
<%= form_for(@person) do |f| %>
  <div class="field">
    <%= f.label :name %><br />
    <%= f.text_field :name %>
  </div>

  <%= f.fields_for :user_account do |acc| %>
    <div class="field">
      <%= acc.label :username %><br />
      <%= acc.text_field :username %>
    </div>
    <div class="field">
      <%= acc.label :password %><br />
      <%= acc.password_field :password %>
    </div>

    <%= acc.fields_for :permissions do |p| %>
      <div class="field">
        <%= p.label p.object.restricted_area %>
        <%= p.hidden_field :restricted_area %>
        <%= p.select :grants, [ "Read-Only", "Read-Write", "Read-Write-Delete" ], include_blank: true %>
      </div>
    <% end %>
  <% end %>

  <div class="actions">
    <%= f.submit %>
  </div>
<% end %>
Entre na tela de cadastro (http://localhost:3000/people/new), para visualizar um formulário contendo os campos de Person, UserAccount e Permission:

Oops. Apenas os campos de Person estão visíveis. Não é difícil conjecturar o porquê. Ao criar um novo objeto Person, ele não possui um user_account, como pode ser confirmado no console:
> Person.new.user_account
 => nil
É necessário sobrescrever o método Person/user_account, para que retorne um novo objeto quando Person não tiver uma conta de usuário associada:
  def user_account
    @user_account = super
    @user_account = build_user_account if @user_account.nil?
    @user_account
  end
Isto é suficiente para habilitar os campos de UserAccount:


Para exibir os campos referentes aos atributos de Permission, criamos um método UserAccount/permissions_types, que pré-instancia as possíveis opções:
  def permissions_types
    hash = {}
    permissions.each { |p| hash[p.restricted_area] = p }

    [ "admin", "backups", "logs" ].each { |area|
      hash[area] = Permission.new restricted_area: area unless hash.has_key? area
    }
    hash.values
  end
Note que as duas primeiras linhas "memorizam" associações previamente salvas, enquanto nas linhas seguintes, são construídos novos objetos apenas para as associações que ainda não estiverem presentes.

É necessário modificar o formulário para construir os campos de permissões utilizando este método:
<%= acc.fields_for :permissions, acc.object.permissions_types do |p| %>
  <div class="field">
    <%= p.label p.object.restricted_area %>
    <%= p.hidden_field :restricted_area %>
    <%= p.select :grants, [ "Read-Only", "Read-Write", "Read-Write-Delete" ], include_blank: true %>
  </div>
<% end %>
Vejamos como ficou:

Agora que os campos já estão presentes no formulário, basta salvar um registro:

Oops... estamos quase lá... Vamos tentar entender o que aconteceu. Com auxílio do Chrome Developer Tools, podemos analisar os dados que são submetidos com o formulário:


Os view helpers do Rails criam campos com nomes que são adequados à sua forma de processar nested models. Se analisarmos o log do controller, vemos que o request foi convertido para um hash, representando estes parâmetros de forma hierárquica (formatei o log para facilitar sua visualização):
Started POST "/people" for 127.0.0.1 at 2012-08-05 22:36:11 -0300
Connecting to database specified by database.yml
Processing by PeopleController#create as HTML
Parameters: {
  "utf8"=>"✓", 
  "authenticity_token"=>"iTFVyyIwp0w2apXT02s+XN3z6PfOFEuUgZRaZWxPegU=",
  "person"=>{
    "name"=>"José da Silva",
    "user_account_attributes"=>{
      "username"=>"jsilva",
      "password"=>"[FILTERED]",
      "permissions_attributes"=>{
        "0"=>{
          "restricted_area"=>"admin",
          "grants"=>"Read-Only"
        },
        "1"=>{
          "restricted_area"=>"backups",
          "grants"=>""
        },
        "2"=>{
          "restricted_area"=>"logs",
          "grants"=>"Read-Write"
        }
      }
    }
  },
  "commit"=>"Create Person"
}
Faz sentido: o rails tentou criar uma permission com o campo grants em branco, mas este campo é obrigatório (vide a parte 2 desta série). É necessário alguma forma de filtrar de permission_attributes os valores que estiverem com grants em branco. Há: Rails possui essa facilidade:
accepts_nested_attributes_for :permissions, allow_destroy: true,
  reject_if: proc { |attrs|
    attrs["grants"].blank?
  }
Basta este pequeno ajuste em UserAccount para conseguimos salvar o registro, que na figura abaixo, foi reaberto para edição:


É incrível: simplesmente funciona! Tudo bem até aqui, já conseguimos salvar. Mas logo descobrimos que o "brinquedo quebra" se tentar remover uma permissão: tente deixar Logs em branco e salvar. Parece que tudo deu certo, mas ao reabrir para edição, a permissão continua lá.

Isto ocorre pois, após termos adicionado a cláusula reject_if, as permissões em branco passaram a ser rejeitadas. O Rails não tem mais a oportunidade de processá-las. Além disso, vimos nos posts anteriores que, para remover um nested model, é necessário passar _destroy: true como um de seus parâmetros.

Pode-se usar um checkbox para adicionar um atributo _destroy:
<%= acc.fields_for :permissions, acc.object.permissions_types do |p| %>
  <%= p.label p.object.restricted_area %>
  <%= p.hidden_field :restricted_area %>
  <%= p.select :grants, [ "Read-Only", "Read-Write", "Read-Write-Delete" ], include_blank: true %>
  <% unless p.object.new_record? %>
    <%= p.check_box :_destroy %>
    <%= p.label :_destroy, 'Remove' %>
  <% end %>
<% end %>
O formulário fica com a seguinte aparência:


E agora, com auxílio do checkbox, conseguimos também remover permissões. Um único formulário, manipulando uma complexa estrutura de dados associados - Person 1 : 1 UserAccount 1 : N Permission.

Note que não foi necessário adicionar nenhuma lógica no controller para interpretar argumentos do request dos objetos associados. Apesar de termos sido obrigados a adicionar um checkbox para remoção de permissões, não precisamos de nenhuma lógica customizada de data-binding para apresentar as associações na view. Tudo out-of-the-box, prontinho para usar, um bonus-track desta fantástica framework web que é o Rails!

No quarto e último post desta série, será mostrada "a cereja do bolo": com um pouquinho de JavaScript, jQuery e Knockout.js, tornaremos a adição de permissões mais dinâmica, além de adicionar dinamicamente o atributo _destroy no request, sem precisar de um checkbox.


Referências