Uma nova funcionalidade adicionada ao Rails 2.3 é a possibilidade de atualizar os atributos em modelos aninhados diretamente.

Para mostrar essa funcionalidade vamos trabalhar numa aplicação de gerenciamento de contatos, permitindo cadastrar pessoas, contatos e cidades. A estrutura é bastante simples: uma pessoa possui muitos contatos e pertence a uma cidade. O código foi baseado no exemplo de gerenciamento de formulários complexos criado pelo Eloy Duran.

Durante o desenvolvimento da aplicação vou criar também alguns testes, contudo eles não serão disponibilizados aqui para não estender demais o post. Quem tiver interesse poderá conferir o código fonte no github. Já aviso a todos que não sou um dos maiores peritos em testes, estou aprendendo, portanto qualquer dica sempre será bem-vinda =).

Bom, vamos lá! Utilizando o terminal, navegue até o diretório onde deseja criar a aplicação e execute os comandos:

rails nested_attributes
cd nested_attributes

Já sabemos que vamos precisar de um modelo pessoa, cidade e contato, então vamos criá-los utilizando os generators do Rails:

script/generate model City name:string
script/generate scaffold Person name:string city:references
script/generate model Contact kind:string description:string person:references

Olhos mais atentos devem ter percebido que o modelo de pessoa foi gerado com scaffold: apenas para facilitar a nossa vida já criando o controller e as views necessárias para a manutenção.

Agora vamos criar e migrar nossa base de dados:

rake db:create
rake db:migrate

Vamos trabalhar inicialmente nos modelos, criando as validações e relacionamentos. Primeiro o modelo de cidades (app/models/city.rb) deve ter muitas pessoas, e deve validar a presença do nome.

class City < ActiveRecord::Base
  has_many :people

  validates_presence_of :name
end
&#91;/sourcecode&#93;

Continuando, nosso modelo de pessoas (<code>app/models/person.rb</code>) deve requerer o nome, possuir muitos contatos e pertencer a uma cidade.


class Person < ActiveRecord::Base
  belongs_to :city
  has_many :contacts

  validates_presence_of :name
end
&#91;/sourcecode&#93;

E por fim temos o modelo de contatos (<code>app/models/contact.rb</code>), que além de pertencer a uma pessoa deve validar a presença do tipo e da descrição.


class Contact < ActiveRecord::Base
  KINDS = %w(home_phone work_phone email url)
  belongs_to :person

  validates_presence_of :kind, :description
  validates_inclusion_of :kind, :in => KINDS
end

Também criamos aqui uma constante para facilitar o trabalho de gerenciar os tipos de contatos possíveis: telefone residencial, telefone comercial, email e url, respectivamente. Se você desejar mais algum tipo de contato é só adicionar nesta lista.

Neste ponto a estrutura de nossos modelos está pronta. Todas as validações e relacionamentos necessários foram criados. Podemos então partir para o que realmente nos interessa: atributos aninhados.

Atributos Aninhados: Os Modelos

Para que um modelo possa criar e salvar dados de modelos relacionados, devemos dar esta permissão a ele explicitamente através do comando accepts_nested_attributes_for. Este comando permite dois parâmetros adicionais: allow_destroy, que quando verdadeiro permitirá a exclusão de registros aninhados através de uma flag :_delete (veja mais abaixo); e reject_if, uma proc que deve validar se um registro aninhado será ou não criado. Vamos fazer isto no nosso modelo de pessoas, adicionando as seguintes linhas:

  accepts_nested_attributes_for :contacts, :allow_destroy => true,
                                :reject_if => proc { |contact| contact['description'].blank? }

  accepts_nested_attributes_for :city,
                                :reject_if => proc { |city| city['name'].blank? }

Para o modelo de contatos estamos permitindo excluir registros, e também ignoramos registros sem o campo descrição. Para a cidade a opção :allow_destroy não foi informada, pois não pretendemos permitir a exclusão de uma cidade a partir da pessoa. Também ignoramos a cidade se o nome estiver em branco.

Para fazer alguns testes com essas novas opções abra um console no terminal (script/console) e vá digitando os comandos abaixo e analisando os resultados:


# City
person = Person.new(:name => 'Carlos')
#<Person id: nil, name: "Carlos", city_id: nil, created_at: nil, updated_at: nil>
person.city_attributes = { :name => 'Rio do Sul' }
#{:name=>"Rio do Sul"}
person.city
#<City id: nil, name: "Rio do Sul", created_at: nil, updated_at: nil>
person.city.new_record?
#true
person.save
#true
person.city.new_record?
#false

# Contacts
person.contacts_attributes = { 'new_1' => {
   :kind => 'email', :description => 'teste@example.com' }}
#{"new_1"=>{:kind=>"email", :description=>"teste@example.com"}}
person.contacts_attributes = { 'new_2' => {
  :kind => 'email', :description => 'teste2@example.com' }}
#{"new_2"=>{:kind=>"email", :description=>"teste2@example.com"}}
person.contacts.size
#2
person.contacts
#[#<Contact id: nil, kind: "email", description: "teste@example.com", person_id: nil, created_at: nil, updated_at: nil>, #<Contact id: nil, kind: "email", description: "teste2@example.com", person_id: nil, created_at: nil, updated_at: nil>]
person.save
#true
person.contacts
#[#<Contact id: 5, kind: "email", description: "teste@example.com", person_id: 6, 
#    created_at: "2009-03-28 00:36:49", updated_at: "2009-03-28 00:36:49">, 
#   <Contact id: 6, kind: "email", description: "teste2@example.com", person_id: 6, 
#    created_at: "2009-03-28 00:36:49", updated_at: "2009-03-28 00:36:49">]

# Deleting a contact
contact_id = person.contacts.first.id
#5
person.contacts_attributes = { contact_id.to_s => { :id => contact_id.to_s, :_delete => true } }
#{"5"=>{:id=>"5", :_delete=>true}}
person.contacts.first.marked_for_destruction?
#true
person.save
#true
person.reload.contacts.size
#1
person.contacts
#[#<Contact id: 6, kind: "email", description: "teste2@example.com", person_id: 6, 
#    created_at: "2009-03-28 00:36:49", updated_at: "2009-03-28 00:36:49">]

Se desejar ver mais exemplos de utilização das estruturas aninhadas dê uma olhada nos testes do projeto no github.

Apenas ativar a opção accepts_nested_attributes_for no modelo nos dá uma série de funcionalidades “de graça”: o Rails agora irá salvar automaticamente os registros atribuídos através desta opção. Além disso, o modelo verifica se existe erros nos modelos filhos e carrega quaisquer mensagens de erros para ele, também para facilitar a visualização de forma amigável na view. E o melhor é que tudo acontece dentro de uma transação única, o que significa que se algum dos registros gerar um erro no momento de salvar ou de algum callback, toda a transação será cancelada.

Atributos Aninhados: As Views

Toda essa funcionalidade que o Active Record disponibiliza não seria de tanta ajuda se não tivéssemos facilitadores para gerenciar isso nos formulários. Para isso ganhamos novas funcionalidades dentro de views: tudo acontece utilizando o método fields_for, de uma maneira um pouquinho diferente de como já o utilizávamos.

A primeira modificação que precisamos fazer é criar um partial com o formulário para inclusão/edição de pessoas. Como geramos esse modelo com o scaffold, a estrutura html inicial já está pronta, então crie um partial com o nome _form.html.erb dentro de app/views/people/ com o código abaixo:

<% form_for(@person) do |f| %>
  <%= f.error_messages %>
  <p>
    <%= f.label :name %><br />
    <%= f.text_field :name %>
  </p>
  <p>
    <%= f.submit 'Submit' %>
  </p>
<% end %>

Percebam que removi a parte gerada para o campo city, pois trabalharemos nele mais tarde. Outra mudança apenas estética: o submit ganhou um caption mais “genérico”, ao invés de “Create” ou “Update” como é gerado nos templates new e edit.

Devemos agora alterar estes últimos dois para gerar nosso partial:

# new.html.erb
<h1>New person</h1>
<%= render :partial => 'form' %>
<%= link_to 'Back', people_path %>

# edit.html.erb
<h1>Editing person</h1>
<%= render :partial => 'form' %>
<%= link_to 'Show', @person %> |
<%= link_to 'Back', people_path %>

Vamos rodar nossa aplicação para vermos como está ficando. Primeiro remova o arquivo index.html que está dentro da pasta public. Depois, abra o arquivo config/routes.rb e modifique-o conforme abaixo:

ActionController::Routing::Routes.draw do |map|
  map.resources :people
  map.root :people
end

Isto configura o root de nossa aplicação para a listagem de pessoas. Agora vá para o terminal e rode o servidor (script/server), depois acesse no navegador http://localhost:3000 e deverá ver a listagem com as pessoas que cadastrou anteriormente. Acesse a tela de edição e de inclusão de nova pessoa para verificar se tudo correu bem com nosso partial.

Pronto. Com tudo funcionando podemos nos concentrar no gerenciamento de contatos para a pessoa no mesmo formulário.

Contatos

A criação de um formulário aninhado utiliza o método fields_for, sendo chamado a partir da instância do formulário que está sendo criado, ou formulário “pai”, e identificando qual o modelo aninhado que será gerado. Acho que ficou um pouco complicado de entender não é? Para simplificar vamos olhar como fica nosso partial _form.html.erb:

<% form_for(@person) do |f| %>
  ........
  <fieldset id="contacts">
    <legend>Contacts</legend>
    <% f.fields_for :contacts do |contacts_form| -%>
      <%= render :partial => 'contact', :locals => { :f => contacts_form }  %>
    <% end -%>
  </fieldset>
  <p>
    <%= f.submit 'Submit' %>
  </p>
<% end %>

Adicionamos um fieldset para agrupar os contatos, e utilizamos o método fields_for a partir da instância do form pessoa (f.fields_for), informando que estamos gerenciando os contatos (:contacts). Para cada contato da pessoa será gerado o partial _contact.html.erb, que iremos criar agora. Perceba também que estamos passando para o partial a instância do formulário atual de contato (contacts_form). Crie um partial chamado _contact.html.erb em app/views/people/ e coloque o seguinte código (lembre-se que a variável f aqui se refere a instância contacts_form):

<div class="contact">
  <p>
    <%= f.label :kind %>
    <%= f.select :kind, Contact::KINDS.map { |kind| &#91;kind.humanize, kind&#93; } %>
    <%= f.label :description %>
    <%= f.text_field :description %>
    <% unless f.object.new_record? -%>
      <%= f.check_box :_delete %><%= f.label :_delete, 'Delete' %>
    <% end -%>
  </p>
</div>

Criamos aqui um select com opções do tipo de contato que estamos criando, e um input para o usuário entrar com o telefone ou e-mail por exemplo, além de uma checkbox para excluir um contato se o mesmo não for um novo registro (f.object nos dá acesso ao objeto ActiveRecord do formulário que está sendo processado, no nosso caso um contato). Isso já facilita bastante a manutenção: para excluir um contato é só marcar a checkbox e enviar o formulário, se tudo correr bem o contato deixará de existir.

Para testar essa funcionalidade crie algumas pessoas e contatos no console e abra a tela de edição: você verá os contatos no mesmo formulário para alterá-los, excluí-los, etc. Para permitir a inclusão de mais contatos nessa mesma tela, altere os métodos new e edit do controller e adicione a seguinte linha para criar alguns contatos em memória, gerando assim alguns formulários de contatos:

3.times { @person.contacts.build }

Até aqui tudo bem. Contudo esbarramos num pequeno problema: a melhor maneira de utilizar essa funcionalidade é permitir que o usuário possa criar quantos contatos ele queira, sem ter que incomodar o servidor a cada momento apenas para gerar uma nova linha no formulário. Da maneira que programamos até aqui, o usuário poderá criar apenas 3 contatos por vez, ou quantos nós decidirmos que seja ideal. Para conseguir isto de forma dinâmica temos que utilizar um pouco de javascript e alguns helpers.

Primeiro temos que modificar o nosso layout people.html.erb em app/views/layouts para que carregue os javascripts padrão, e também vamos adicionar um método para poder escrever algum código javascript manualmente a partir dos templates que usam esse layout. Localize a linha que adiciona o stylesheet do scaffold e coloque o seguinte código acima dela:

  <%= javascript_include_tag :defaults %>
  <% javascript_tag do %>
    <%= yield(:javascript) %>
  <% end -%>

Vamos adicionar um link para criar novos contatos, e teremos que programar este link para gerar um novo formulário com javascript.

  <fieldset id="contacts">
    ......
    <p><%= link_to 'Add contact', '#contacts', :class => 'add' %></p>
  </fieldset>

Antes de fazermos com que o o link adicione o formulário, vamos apenas programá-lo para adicionar algum texto no local onde o mesmo deveria ser posicionado: desta forma só precisamos gerar um novo formulário e colocá-lo no lugar do texto, e tudo estará funcionando. A classe 'add' será usada para identificarmos o link e manipularmos a função click dele. O segundo parâmetro do link_to, que seria o caminho para onde o link apontaria, vai ser utilizado mais tarde para identificar o template correto a ser renderizado (por enquanto vamos renderizar apenas um texto, ok?). Abra o application.js e adicione o seguinte código nele:

var NestedAttributesJs = {
  add : function(e) {
    element = Event.findElement(e);
    element.insert( { before: 'AQUI VAI O NOSSO FORMULARIO<br/>' } )
  }
}

Event.observe(window, 'load', function(){
  $$('.add').each(function(link){
    link.observe('click', NestedAttributesJs.add);
  });
});

O que acontece aqui: após todo o DOM da página ser carregado, usamos o método $$('.add') para encontrar todos os elementos com a classe add (lembram do nosso link?), percorrendo então o array de elementos encontrados (apenas um por enquanto) com o método each e adicionando um observador para o evento click. Em resumo, quando cada elemento com essa classe for clicado, irá disparar a função NestedAttributesJs.add. Dentro desta função buscamos o elemento atual passado como parâmetro (nesse caso o nosso link que está sendo clicado) e inserimos antes dele um texto e uma quebra de linha html. É exatamente no lugar deste texto que vamos renderizar nosso formulário, mas para fazermos isto temos que ter um template do formulário já gerado em alguma variável javascript, para apenas mudar o nosso texto temporário por esta variável. Vamos criar alguns helpers para nos ajudar nesta tarefa, mas antes faça alguns testes no seu navegador clicando no link e vendo onde o texto está sendo inserido. Após, abra o PeopleHelper e adicione o seguinte código nele:

  def nested_attributes_for(form_builder, *args)
    content_for :javascript do
      content = ""
      args.each do |association|
        content << "\nvar #{association}_template='#{generate_template(form_builder, association.to_sym)}';"
      end
      content
    end
  end

  def generate_html(form_builder, method, options = {})
    options&#91;:object&#93; ||= form_builder.object.class.reflect_on_association(method).klass.new
    options&#91;:partial&#93; ||= method.to_s.singularize
    options&#91;:form_builder_local&#93; ||= :f

    form_builder.fields_for(method, options&#91;:object&#93;, :child_index => 'NEW_RECORD') do |f|
      render(:partial => options[:partial], :locals => { options[:form_builder_local] => f })
    end
  end

  def generate_template(form_builder, method, options = {})
    escape_javascript generate_html(form_builder, method, options = {})
  end

São três métodos para estudarmos aqui:

  • generate_html: cria um novo objeto da classe que estamos gerando (o mesmo que Contact.new no nosso caso), e o partial para esse novo objeto utilizando o fields_for. O resultado é um partial vazio para incluir um registro aninhado. Um pouco de atenção ao parâmetro :child_index => 'NEW_RECORD': ele será bastante importante. Este parâmetro diz ao fields_for para utilizar a string NEW_RECORD como índice do registro ao gerar este formulário, ao invés de utilizar um contador, como estávamos fazendo manualmente ao criar novos contatos aninhados ('new_1', 'new_2'). Se tiver dúvidas quanto a esta parte, volte um pouco até onde falamos sobre os testes no console e verifique como estávamos criando novos contatos;
  • generate_template: dispara o método generate_html, escapando caracteres javascript (lembre-se: tudo isto irá parar em uma variável javascript);
  • nested_attributes_for: cria uma ou mais variáveis javascript, cada uma contendo o respectivo template gerado para formulário, jogando o conteúdo no cabeçalho do documento (adicionamos esta possibilidade anteriormente com yield(:javascript) no layout).

Com estes novos métodos helpers podemos gerar agora nossa variável javascript contendo o formulário vazio para a criação de novos contatos. Vale lembrar que estes métodos funcionarão posteriormente em qualquer formulário aninhado que você precise, seguindo as mesmas convenções de nomes que seguimos aqui. Adicione ao partial _form.html.erb logo abaixo do form_for:

  <% nested_attributes_for f, :contacts -%>

Ao atualizar a página agora e visualizar o código fonte, poderá ver que no cabeçalho será criada uma variável javascript chamada contacts_template que possui o html necessário para gerar o novo formulário. Aproveite para analisar também como a string NEW_RECORD foi posicionada dentro do nosso template gerado.

  <script type="text/javascript">
//<!&#91;CDATA&#91;
var contacts_template='<div class=\"contact\">\n  <p>\n    <label for=\"person_contacts_attributes_NEW_RECORD_kind\">Kind<\/label>\n    <select id=\"person_contacts_attributes_NEW_RECORD_kind\" name=\"person&#91;contacts_attributes&#93;&#91;NEW_RECORD&#93;&#91;kind&#93;\"><option value=\"home_phone\">Home phone<\/option>\n<option value=\"work_phone\">Work phone<\/option>\n<option value=\"email\">Email<\/option>\n<option value=\"url\">Url<\/option><\/select>\n    <label for=\"person_contacts_attributes_NEW_RECORD_description\">Description<\/label>\n    <input id=\"person_contacts_attributes_NEW_RECORD_description\" name=\"person&#91;contacts_attributes&#93;&#91;NEW_RECORD&#93;&#91;description&#93;\" size=\"30\" type=\"text\" />\n\n      <\/p>\n<\/div>\n\n';
//&#93;&#93;>
</script>

Vamos alterar agora nosso javascript para utilizar o template. Você se lembra quando criamos o link para adicionar um novo contato, passamos o parâmetro #contacts como se fosse o caminho para o link? Vamos utilizar a propriedade href do link, que possui esse texto, remover o # e obter nosso template que se chama contacts_template. Por isso a importância da convenção =). Substitua o código do application.js pelo abaixo:

var NestedAttributesJs = {
  add : function(e) {
    element = Event.findElement(e);
    template = eval(element.href.replace(/.*#/, '') + '_template');
    element.insert( { before: NestedAttributesJs.replace_ids(template) } );
  },
  replace_ids : function(template){
    var new_id = new Date().getTime();
    return template.replace(/NEW_RECORD/g, new_id);
  }
}

Event.observe(window, 'load', function(){
  $$('.add').each(function(link){
    link.observe('click', NestedAttributesJs.add);
  });
});

O que mudou: dentro do método NestedAttributesJs.add estamos atribuindo à variável template o conteúdo da nossa variável contacts_template através do método eval. Agora um ponto importante: adicionamos um método chamado replace_ids, que substitui no nosso template todas as ocorrências de NEW_RECORD pelo número de milisegundos desde a meia-noite de primeiro de janeiro de 1970, gerados pela função Date().getTime(). A idéia é que seja sempre gerado um número novo a cada registro adicionado ao formulário. Se você verificar o código gerado através da extensão Web Developer do Firefox, verá que ao invés de person[contacts_attributes][NEW_RECORD] temos algo como person[contacts_attributes][1238198708416] nos campos do novo formulário. Desta forma o Rails saberá identificar que este é um novo registro. Caso isto não seja feito, se você criar 3 contatos no seu formulário e enviá-lo para o servidor, o Rails receberá três contatos com NEW_RECORD e salvará apenas um deles.

Bom, neste ponto já devemos ter nossa estrutura funcionando para adicionar e remover contatos aninhados. Lembre-se que estamos ignorando contatos sem descrição, portanto você pode criar 10 contatos no formulário sem preencher sua descrição, que ao postar o Rails ignorará esses contatos.

Uma outra funcionalidade interessante seria a possibilidade de excluir um contato adicionado, mas não temos como fazer isto através de checkboxes pois o formulário está sendo criado dinamicante. Vamos criar um link para remover o html gerado para o novo registro. Para facilitar vamos mover o código que cria ou um link ou uma checkbox para remoção do registro aninhado, se o registro for ou não novo, permitindo assim reutilizar este código em outros templates posteriormente. Adicione ao PeopleHelper:

  def remove_link_unless_new_record(form_builder)
    unless form_builder.object.new_record?
      form_builder.check_box(:_delete) + form_builder.label(:_delete, 'Delete')
    else
      link_to_function('Delete', "$(this).up('.#{form_builder.object.class.name.underscore}').remove();")
    end
  end

E altere o partial _contact.html.erb conforme abaixo:

    # remova estas linhas
    #<% unless f.object.new_record? -%>
    #  <%= f.check_box :_delete %><%= f.label :_delete, 'Delete' %>
    #<% end -%>

    # e adicione esta no lugar
    <%= remove_link_unless_new_record(f) %>

Isto já deve ser suficiente para utilizarmos este cadastro incluindo e excluindo contatos dinamicamente. Podemos partir para a seleção de uma cidade para a pessoa.

Cidade

Quando trabalhamos com este tipo de relação entre pessoa e cidade, normalmente criamos um select com as cidades disponíveis para que o usuário selecione qual a cidade que a pessoa pertence. Muitas vezes gostaríamos de permitir que uma cidade possa ser criada junto com a pessoa, caso ela não exista na lista. E o Rails agora permite fazer isto de maneira simples utilizando atributos aninhados. Para que isto funcione precisamos apenas fazer uma alteração na view, adicionando abaixo do campo nome:

  <p>
    <%= f.label :city_id %>
    <%= f.collection_select :city_id, City.all(:order => 'name'), :id, :name %>
    or
    <% f.fields_for :city, City.new do |city_form| -%>
      <%= city_form.label :name, 'Create a new' %><%= city_form.text_field :name %>
    <% end -%>
  </p>

O código acima cria um select com as cidades disponíveis ordenadas por nome (ok eu sei, a chamada ao City.all(:order => 'name') não deveria estar aqui, mas estou apenas simplificando pois o post já está bastante extenso), e também um input para a criação de uma nova cidade. Desta forma, o usuário tem a possibilidade de ou selecionar uma cidade pelo select ou entrar com o nome de uma nova cidade.

Para terminar

Ufa! Foi um pouco extensivo mas é isso aí. Após um pouco de trabalho temos uma manutenção totalmente funcional que trabalha com atributos aninhados.

O projeto que criamos aqui está disponível no github.

Espero ter ajudado a quem estava com dúvidas sobre a utilização desta nova funcionalidade.

Qualquer dúvida, dificuldade ou crítica por favor poste um comentário que terei prazer em responder assim que possível.