Posts Tagged ‘recipes’

Tratando Múltiplos Models em Um Único Form

Tuesday, January 13th, 2009

O livro Advanced Rails Recipes é um livro muito interessante para quem quer aprender algumas dicas mais avançadas de Rails. Especialmente aquele tipo de dicas que você não encontra em tutoriais ou blogs por aí. Da descrição do site:

“Você irá aprender como os profissionais resolveram problemas complicados usando as técnicas mais atualizadas de Rails 2, para que você possa entregar a sua impressionante aplicação Web de forma mais fácil e mais rápida.”

Esta é uma tradução para o português, com autorização de Dave Thomas de Pragmatic Programers. Aproveite! :)

Tratando Múltiplos Models em Um Único Form

Por Ryan Bates (http://railscasts.com/). Ryan trabalha com desenvolvimento Web desde 1998. Começou a trabalhar profissionalmente com Ruby on Rails em 2005 e é mais conhecido pelo seu trabalho com os Railscasts, uma série gratuita de screencasts sobre Ruby on Rails.

Problema

A maioria do código Rails que você encontra geralmente trata um model de cada vez. Isso nem sempre é prático. Algumas vezes você precisa criar e/ou editar dois (ou mais) models em um único form, nas situações onde você tem uma associação one-to-many entre os models.

Solução

Digamos que nós precisamos salvar todas as tarefas pendentes para vários projetos. Quando nós criarmos ou atualizarmos um projeto, nós gostaríamos de adicionar, remover e atualizar suas tarefas, em um único form. O que estamos imaginando é:

Vamos começar criando um relacionamento has_many entre Project e Task. Para simplificar, vamos dar para cada model um atributo obrigatório chamado name.


# Arquivo: app/models/project.rb
class Project < ActiveRecord::Base
  has_many :tasks, :dependent => :destroy
  validates_presence_of :name
end

# Arquivo: app/models/task.rb
class Task < ActiveRecord::Base
  belongs_to :project
  validates_presence_of :name
end

Nós vamos usar a biblioteca de Javascript Prototype, então antes de qualquer coisa vamos garantir que ela está carregada em nosso arquivo de layout:


# Arquivo: app/views/layouts/application.html.erb
<%= javascript_include_tag :defaults %>

Vamos voltar a nossa atenção para o form, para criar um projeto com suas múltiplas tarefas, e associadas ao projeto. Quando precisamos tratar múltiplos models em um form, é muito útil eleger um model como sendo o foco primário ou principal, e a partir daí construir os models adicionais através da associação entre eles.

Neste caso, vamos fazer de Project o nosso model primário e construir suas tarefas através da associação has_many. Então para a action new de nosso ProjectsController, nós criamos um objeto Project da forma usual. No entanto, nós também inicializamos uma nova Task (em memória) que é associada com o Project de forma que nosso form tem alguma coisa para começar a trabalhar:


# Arquivo: app/controllers/projects_controller.rb
def new
  @project = Project.new
  @project.tasks.build
end

O template para o form precisa de algumas “manhas” já que nós precisamos tratar os campos para o model Project e também os campos de cada um dos models Task. Então, vamos quebrar o problema em partes menores e usar um partial para renderizar os campos da Task e adicionar um helper add_task_link para criar o link que adiciona uma nova tarefa:


# Arquivo: app/views/projects/_form.html.erb
<%= error_messages_for :project %>

<% form_for @project do |f| -%>
  <p>
  Name: <%= f.text_field :name %>
  </p>
  <div id="tasks">
    <%= render :partial => 'task', :collection => @project.tasks %>
  </div>
  <p>
    <%= add_task_link "Add a task" %>
  </p>
  <p>
    <%= f.submit "Submit" %>
  </p>
<% end -%>

Os templates new e edit simplesmente renderizam este partial de formulário, de forma que nós temos uma forma consistente para criar e atualizar um projeto. A partial do form vai e renderiza uma partial de tarefa para cada uma das tarefas do projeto. Antes de entrarmos no conteúdo da partial de tarefa, vamos dar uma olhada naquele helper add_task_link:


# Arquivo: app/helpers/projects_helper.rb
def add_task_link(name)
  link_to_function name do |page|
    page.insert_html :bottom, :tasks, :partial => 'task' , :o bject => Task.new
  end
end

Quando nós clicamos no link “Add a Task”, nós queremos um novo conjunto de campos de tarefas aparecendo abaixo dos campos de tarefa que já existem no formulário. Ao invés de ocupar o servidor com isto, nós podemos usar JavaScript para adicionar os campos dinamicamente. O método link_to_function aceita um bloco de código RJS. Geralmente nós associamos código RJS com chamadas assíncronas indo para o servidor. Mas neste caso o código RJS gera JavaScript que é executado no browser imediatamente quando o usuário clica no link. O resultado é que renderizar os campos para adicionar uma nova tarefa não requer uma requisição enviada ao servidor, o que leva a um tempo de resposta mais rápido no uso da aplicação.

Voltando ao partial do form, nós estamos usando form_for para dedicar o form para o model @project. Como então nós vamos adicionar campos para cada uma das tarefas do projeto? A resposta está dentro da partial de tarefa:


# Arquivo: app/views/projects/_task.html.erb
<div class="task">
<% new_or_existing = task.new_record? ? 'new' : 'existing' %>
<% prefix = "project[#{new_or_existing}_task_attributes][]" %>

<% fields_for prefix, task do |task_form| -%>
  <p>
    Task: <%= task_form.text_field :name %>
    <%= link_to_function "remove" , "$(this).up('.task').remove()" %>
  </p>
<% end -%>
</div>

O ingrediente chave aqui é o método fields_for. Ele se comporta de forma muito parecida com form_for mas ele não renderiza a tag HTML form. Isto nos permite mudar o contexto para um model diferente, no meio do form principal – como se nós estivéssemos embutindo um form dentro de outro.

O primeiro parâmetro para fields_for é muito importante. Esta string será usada como um prefixo para cada campo de tarefa. Como vamos usar esta partial também para renderizar tarefas existentes – e nós queremos mantê-las separadas quando o form é enviado – no prefixo nós incluímos uma indicação dizendo se a tarefa é nova ou existente. (O ideal seria criar esta string de prefixo em um helper, mas vamos simplificar um pouco as coisas aqui.)

O HTML gerado para uma nova tarefa se parece com isto:


<input name="project[new_task_attributes][][name]" size="30" type="text" />

Se esta fosse uma tarefa existente, o Rails iria colocar automaticamente o ID da tarefa entre as chaves, assim:


<input name="project[existing_task_attributes][7][name]" size="30" type="text" />

Agora, quando o form é enviado, o Rails irá decodificar o nome do campo de entrada, para forçar uma estrutura no hash params. As chaves ([]) que estão preenchidas se tornam keys em um hash aninhado. As chaves que estão vazias se tornam um array. Por exemplo, se nós enviarmos o form com duas novas tarefas, o hash params vai ficar assim:


"project" => {
  "name" => "Yard Work" ,
  "new_task_attributes" => [
    { "name" => "rake the leaves" },
    { "name" => "paint the fence" }
  ]
}

Note que os atributos para o projeto e cada tarefa estão aninhados dentro do hash project. Isto é conveniente porque significa que a action create em nosso controller pode simplesmente passar todos os atributos de projeto ao model Project sem se preocupar sobre o que está dentro do hash project:


# Arquivo: app/controllers/projects_controller.rb
def create
  @project = Project.new(params[:project])
  if @project.save
    flash[:notice] = "Successfully created project and tasks."
    redirect_to projects_path
  else
    render :action => 'new'
  end
end

Este código se parece com uma action create padrão, para um form de um único model. Mas existe algo sútil acontecendo aqui. Quando chamamos Project.new(params[:project]), o Active Record assume que nosso model Project tem um atributo correspondente chamado new_task_attributes porque ele procura uma key chamada new_task_attributes dentro do hash params[:project]. Isto é, o Active Record tentará fazer uma atribuição em massa (mass assign) de todos os dados contidos neste hash, para os atributos correspondentes no model Project. Mas nós não temos um atributo new_task_attributes em nosso model Project.

Um jeito conveniente de manter tudo isto transparente, da ponto de vista do controller, é usar um atributo virtual. Para fazer isso, nós simplesmente criamos um método setter em nosso model Project, chamado new_task_attributes=, que recebe um array e constrói a tarefa para cada elemento:


# Arquivo: app/models/project.rb
def new_task_attributes=(task_attributes)
  task_attributes.each do |attributes|
    tasks.build(attributes)
  end
end

Pode parecer que estas tarefas não estão sendo salvas em lugar nenhum. De fato, o Rails vai fazer isso automaticamente quando o projeto é salvo, porque ambos o projeto e suas tarefas associadas, são novos registros.

E isso é tudo que precisamos para criar um projeto. Vamos ver agora como atualizá-lo.

Assim como antes, nós precisamos de uma forma de adicionar e remover tarefas dinamicamente, mas desta vez se uma tarefa já existe, ela deve ser atualizada. As actions do controller só precisam se preocupar sobre o projeto, então elas são bem convencionais. Como antes, a atualização de tarefas será tratada no model Project:


# Arquivo: app/controllers/projects_controller.rb
def edit
  @project = Project.find(params[:id])
end

def update
  params[:project][:existing_task_attributes] ||= {}

  @project = Project.find(params[:id])
  if @project.update_attributes(params[:project])
    flash[:notice] = "Successfully updated project and tasks."
    redirect_to project_path(@project)
  else
    render :action => 'edit'
  end
end

Um detalhe importante: a primera linha da action update seta o parâmetro existing_task_attributes para um hash vazio, se ele já não está setado. Sem esta linha, não teria como deletar a última tarefa de um projeto. Se não existem campos de tarefa no form (porque nós removemos todos eles com JavaScript), então existing_task_attributes() não será setado pelo form, o que significa que o nosso método Project#existing_task_attributes= não será invocado. Setando um hash vazio aqui, se existing_task_attributes() está vazio, garante que o método Project#existing_task_attributes= é chamado ao deletar a última tarefa.

A partial de form não precisa de alterações. No entanto, quando nós submetemos o form com tarefas existentes, o hash params[:project] irá incluir uma key chamada existing_task_attributes. Isto é, quando nós atualizamos o projeto, os parâmetros do POST irão se parecer com isto:


"project" => {
  "name" => "Yard Work" ,
  "existing_task_attributes" => [
    {
      "1" => {"name" => "rake the leaves" },
      "2" => {"name" => "paint the fence" },
    }
  ]
  "new_task_attributes" => [
    { "name" => "clean the gutters" }
  ]
}

Para tratar isto, nós precisamos adicionar um método existing_task_attributes= ao nosso model Project, que irá receber cada tarefa existente e aí vai: ou atualizar a tarefa ou deletá-la, dependendo se os atributos são passados:


# Arquivo: app/models/project.rb
after_update :save_tasks

def existing_task_attributes=(task_attributes)
  tasks.reject(&:new_record?).each do |task|
    attributes = task_attributes[task.id.to_s]
    if attributes
      task.attributes = attributes
    else
      tasks.delete(task)
    end
  end
end

def save_tasks
  tasks.each do |task|
    task.save(false)
  end
end

Note que nós estamos salvando as tarefas em um callback chamado after_update. Isto é importante porque, diferente de antes, as tarefas existentes não serão automaticamente salvas quando o projeto for atualizado. E já que os callbacks são encapsulados em uma transação, se algum problema inesperado acontecer será feito um roll back.

Ao passar false para o método task.save, os dados são salvos sem passar pela validação. Ao invés disso, para garantir que todas as tarefas sejam validadas quando o projeto é validado, nós simplesmente adicionamos esta linha ao model Project:


validates_associated :tasks

Isto vai garantir que tudo é valido antes de salvar. E se a validação falha, então o uso de error_messages_for :project no template de formulário inclui os erros de validação para o projeto e qualquer uma de suas tarefas.

Então agora nós podemos criar e editar projetos e suas tarefas em uma tacada só. E ao usar atributos virtuais, nós mantemos o código do controller felizmente ignorante que nós estamos tratando vários models a partir de um único formulário.

Discussão

Uma vez que você começa a colocar mais de um model em um único formulário, você provavelmente vai querer criar um helper customizado para mensagens de erro, para fazer coisas como ignorar certos erros e detalhar outros. Veja a receita 17 "Customize as Mensagens de Erro", na página 91 para saber como escrever um método error_messages_for customizado. (Nota do tradutor: capítulo disponível no livro Advanced Rails Recipes)

Campos de data causam alguns problemas porque, por algum motivo, o Rails remove as chaves [] do nome do campo. Isto pode ser arrumado especificando manualmente a opção :index e setá-lo para uma string vazia se a tarefa é nova:


<%= task_form.date_select :completed_at,
:index => (task.new_record? ? '' : nil) %>

Infelizmente, campos do tipo checkbox não vão funcionar com esta receita porque o valor destes campos não é passado pelo browser quando o checkbox é desmarcado. Portanto, você não tem como saber a qual tarefa um checkbox pertence quando estiver criando um novo projeto. Para resolver este problema, você pode usar um menu select para campos booleanos:


<%= task_form.select :completed, [['No' , false], ['Yes' , true]] %>

Copyright (c) 2008 The Pragmatic Programmers, LLC