使用jQuery在Rails中不显眼的动态表单字段

时间:2009-11-09 21:54:24

标签: jquery ruby-on-rails ajax forms

我试图克服Rails中动态表单字段的障碍 - 这似乎是框架无法正常处理的东西。我也在我的项目中使用jQuery。我已经安装了jRails,但我宁愿在可能的情况下不引人注意地编写AJAX代码。

我的表单相当复杂,两层或三层嵌套并不罕见。我遇到的问题是生成正确的表单ID,因为它们依赖于表单生成器上下文。我需要能够动态添加新字段或删除has_many关系中的现有记录,我完全不知所措。

到目前为止,我见过的每个例子都以这种或那种方式丑陋。 Ryan Bates'tutorial需要RJS,这会在标记中产生一些非常丑陋的突出javascript,并且似乎是在嵌套属性之前编写的。我已经看到了fork那个带有不引人注意的jQuery的例子,但我只是不明白它在做什么,并且无法让它在我的项目中运行。

有人能提供一个如何做到的简单示例吗?在尊重控制器的RESTful约定时,这是否可行?


Andy发布了一个删除现有记录的优秀示例,任何人都可以提供使用正确属性创建新字段的示例吗?我无法弄清楚如何使用嵌套表单。

6 个答案:

答案 0 :(得分:53)

由于没有人提供这方面的答案,即使在赏金之后,我终于设法让自己得到了这个工作。这不应该是一个傻瓜!希望这在Rails 3.0中更容易实现。

Andy的示例是直接删除记录的好方法,无需向服务器提交表单。在这种特殊情况下,我真正想要的是在更新嵌套表单之前动态添加/删除字段的方法。这是一个略有不同的情况,因为删除字段后,在提交表单之前不会实际删除它们。我可能最终会根据情况使用两者。

我的实现基于github上的Tim Riley's complex-forms-examples fork。

首先设置模型,并确保它们支持嵌套属性:

class Person < ActiveRecord::Base
  has_many :phone_numbers, :dependent => :destroy
  accepts_nested_attributes_for :phone_numbers, :reject_if => lambda { |p| p.values.all?(&:blank?) }, :allow_destroy => true
end

class PhoneNumber < ActiveRecord::Base
  belongs_to :person
end

为PhoneNumber的表单字段创建部分视图:

<div class="fields">
  <%= f.text_field :description %>
  <%= f.text_field :number %>
</div>

接下来编写Person模型的基本编辑视图:

<% form_for @person, :builder => LabeledFormBuilder do |f| -%>
  <%= f.text_field :name %>
  <%= f.text_field :email %>
  <% f.fields_for :phone_numbers do |ph| -%>
    <%= render :partial => 'phone_number', :locals => { :f => ph } %>
  <% end -%>
  <%= f.submit "Save" %>
<% end -%>

这可以通过为我们可以使用javascript复制的PhoneNumber模型创建一组模板字段来实现。我们将在app/helpers/application_helper.rb中为此创建辅助方法:

def new_child_fields_template(form_builder, association, options = {})
  options[:object] ||= form_builder.object.class.reflect_on_association(association).klass.new
  options[:partial] ||= association.to_s.singularize
  options[:form_builder_local] ||= :f

  content_tag(:div, :id => "#{association}_fields_template", :style => "display: none") do
    form_builder.fields_for(association, options[:object], :child_index => "new_#{association}") do |f|
      render(:partial => options[:partial], :locals => { options[:form_builder_local] => f })
    end
  end
end

def add_child_link(name, association)
  link_to(name, "javascript:void(0)", :class => "add_child", :"data-association" => association)
end

def remove_child_link(name, f)
  f.hidden_field(:_destroy) + link_to(name, "javascript:void(0)", :class => "remove_child")
end

现在将这些辅助方法添加到编辑部分:

<% form_for @person, :builder => LabeledFormBuilder do |f| -%>
  <%= f.text_field :name %>
  <%= f.text_field :email %>
  <% f.fields_for :phone_numbers do |ph| -%>
    <%= render :partial => 'phone_number', :locals => { :f => ph } %>
  <% end -%>
  <p><%= add_child_link "New Phone Number", :phone_numbers %></p>
  <%= new_child_fields_template f, :phone_numbers %>
  <%= f.submit "Save" %>
<% end -%>

你现在已经完成了js模板化。它将为每个关联提交一个空白模板,但模型中的:reject_if子句将丢弃它们,只留下用户创建的字段。 更新: 我重新考虑了这个设计,见下文。

这不是真正的AJAX,因为除了页面加载和表单提交之外没有任何通信进入服务器,但老实说在事后我找不到办法。

事实上,这可能会提供比AJAX更好的用户体验,因为在完成之前,您不必等待每个额外字段的服务器响应。

最后我们需要用javascript连接它。将以下内容添加到`public / javascripts / application.js'文件中:

$(function() {
  $('form a.add_child').click(function() {
    var association = $(this).attr('data-association');
    var template = $('#' + association + '_fields_template').html();
    var regexp = new RegExp('new_' + association, 'g');
    var new_id = new Date().getTime();

    $(this).parent().before(template.replace(regexp, new_id));
    return false;
  });

  $('form a.remove_child').live('click', function() {
    var hidden_field = $(this).prev('input[type=hidden]')[0];
    if(hidden_field) {
      hidden_field.value = '1';
    }
    $(this).parents('.fields').hide();
    return false;
  });
});

此时你应该有一个准系统动态表格!这里的javascript非常简单,可以很容易地用其他框架完成。例如,您可以使用prototype + lowpro轻松替换我的application.js代码。基本思想是你没有在你的标记中嵌入巨大的javascript函数,你不必在模型中编写繁琐的phone_numbers=()函数。一切正常。万岁!


经过一些进一步的测试后,我得出结论,模板需要移出<form>字段。将它们保存在那里意味着它们会与表单的其余部分一起被发送回服务器,这只会在以后产生麻烦。

我已将此添加到我的布局底部:

<div id="jstemplates">
  <%= yield :jstemplates %>
</div

并修改了new_child_fields_template助手:

def new_child_fields_template(form_builder, association, options = {})
  options[:object] ||= form_builder.object.class.reflect_on_association(association).klass.new
  options[:partial] ||= association.to_s.singularize
  options[:form_builder_local] ||= :f

  content_for :jstemplates do
    content_tag(:div, :id => "#{association}_fields_template", :style => "display: none") do
      form_builder.fields_for(association, options[:object], :child_index => "new_#{association}") do |f|        
        render(:partial => options[:partial], :locals => { options[:form_builder_local] => f })        
      end
    end
  end
end

现在,您可以从模型中删除:reject_if子句,并停止担心要发回的模板。

答案 1 :(得分:3)

根据您对我评论的回答,我认为处理删除不引人注意是一个很好的起点。我将使用带脚手架的产品作为示例,但代码将是通用的,因此它应该易于在您的应用程序中使用。

首先为您的路线添加新选项:

map.resources :products, :member => { :delete => :get }

现在为产品视图添加删除视图:

<% title "Delete Product" %>

<% form_for @product, :html => { :method => :delete } do |f| %>
  <h2>Are you sure you want to delete this Product?</h2>
  <p>
    <%= submit_tag "Delete" %>
    or <%= link_to "cancel", products_path %>
  </p>
<% end %>

只有禁用JavaScript的用户才会看到此视图。

在Products控制器中,您需要添加删除操作。

def delete
  Product.find(params[:id])
end

现在转到索引视图并将Destroy链接更改为:

<td><%= link_to "Delete", delete_product_path(product), :class => 'delete' %></td>

如果您此时运行应用并查看产品列表,您将能够删除产品,但我们可以为启用JavaScript的用户做得更好。添加到删除链接的类将在我们的JavaScript中使用。

这将是一个相当大的JavaScript块,但重点关注处理ajax调用的代码 - ajaxSend处理程序中的代码和'a.delete'单击处理程序。

(function() {
  var originalRemoveMethod = jQuery.fn.remove;
  jQuery.fn.remove = function() {
    if(this.hasClass("infomenu") || this.hasClass("pop")) {
      $(".selected").removeClass("selected");
    }
    originalRemoveMethod.apply(this, arguments);
  }
})();

function isPost(requestType) {
  return requestType.toLowerCase() == 'post';
}

$(document).ajaxSend(function(event, xhr, settings) {
  if (isPost(settings.type)) {
    settings.data = (settings.data ? settings.data + "&" : "") + "authenticity_token=" + encodeURIComponent( AUTH_TOKEN );
  }
  xhr.setRequestHeader("Accept", "text/javascript, application/javascript");     
});

function closePop(fn) {
  var arglength = arguments.length;
  if($(".pop").length == 0) { return false; }
  $(".pop").slideFadeToggle(function() { 
    if(arglength) { fn.call(); }
    $(this).remove();
  });    
  return true;
}

$('a.delete').live('click', function(event) {
  if(event.button != 0) { return true; }

  var link = $(this);
  link.addClass("selected").parent().append("<div class='pop delpop'><p>Are you sure?</p><p><input type='button' value='Yes' /> or <a href='#' class='close'>Cancel</a></div>");
  $(".delpop").slideFadeToggle();

  $(".delpop input").click(function() {
    $(".pop").slideFadeToggle(function() { 
    $.post(link.attr('href').substring(0, link.attr('href').indexOf('/delete')), { _method: "delete" },
      function(response) { 
        link.parents("tr").fadeOut(function() { 
          $(this).remove();
        });
      });
      $(this).remove();
    });    
  });

  return false;
});

$(".close").live('click', function() {
  return !closePop();
});

$.fn.slideFadeToggle = function(easing, callback) {
  return this.animate({opacity: 'toggle', height: 'toggle'}, "fast", easing, callback);
};

这也是你需要的CSS:

.pop {
  background-color:#FFFFFF;
  border:1px solid #999999;
  cursor:default;
  display: none;
  position:absolute;
  text-align:left;
  z-index:500;
  padding: 25px 25px 20px;
  margin: 0;
  -webkit-border-radius: 8px; 
  -moz-border-radius: 8px; 
}

a.selected {
  background-color:#1F75CC;
  color:white;
  z-index:100;
}

当我们进行POST,PUT或DELETE时,我们需要发送身份验证令牌。在现有的JS标记下添加此行(可能在您的布局中):

<%= javascript_tag "var AUTH_TOKEN = #{form_authenticity_token.inspect};" if protect_against_forgery? -%>

几乎完成了。打开应用程序控制器并添加以下过滤器:

before_filter :correct_safari_and_ie_accept_headers
after_filter :set_xhr_flash

以及相应的方法:

protected
  def set_xhr_flash
    flash.discard if request.xhr?
  end

  def correct_safari_and_ie_accept_headers
    ajax_request_types = ['text/javascript', 'application/json', 'text/xml']
    request.accepts.sort!{ |x, y| ajax_request_types.include?(y.to_s) ? 1 : -1 } if request.xhr?
  end

如果是ajax呼叫,我们需要丢弃flash消息 - 否则你会在下一个常规http请求中看到来自“过去”的flash消息。 webkit和IE浏览器也需要第二个过滤器 - 我将这两个过滤器添加到我的所有Rails项目中。

剩下的就是破坏行动:

def destroy
  @product.destroy
  flash[:notice] = "Successfully destroyed product."

  respond_to do |format|
    format.html { redirect_to redirect_to products_url }
    format.js  { render :nothing => true }
  end
end

你有它。使用Rails不显眼地删除。似乎很多工作都打了出来,但一旦你开始,它真的不是那么糟糕。您可能也对此Railscast感兴趣。

答案 2 :(得分:2)

顺便说一下。 rails已经改变了一点,所以你不能再使用_delete,现在使用_destroy。

def remove_child_link(name, f)
  f.hidden_field(:_destroy) + link_to(name, "javascript:void(0)", :class => "remove_child")
end

此外,我发现删除用于新记录的html更容易......所以我这样做

$(function() {
  $('form a.add_child').click(function() {
    var association = $(this).attr('data-association');
    var template = $('#' + association + '_fields_template').html();
    var regexp = new RegExp('new_' + association, 'g');
    var new_id = new Date().getTime();

    $(this).parent().before(template.replace(regexp, new_id));
    return false;
  });

  $('form a.remove_child').live('click', function() {
    var hidden_field = $(this).prev('input[type=hidden]')[0];
    if(hidden_field) {
      hidden_field.value = '1';
    }
    $(this).parents('.new_fields').remove();
    $(this).parents('.fields').hide();
    return false;
  });
});

答案 3 :(得分:2)

仅供参考,Ryan Bates现在拥有一款效果很好的宝石:nested_form

答案 4 :(得分:1)

我创建了一个不显眼的jQuery插件,用于为Rails 3动态添加fields_for对象。它非常易于使用,只需下载js文件即可。几乎没有配置。按照惯例,你很高兴。

https://github.com/kbparagua/numerous.js

它不是超级灵活,但它可以完成这项工作。

答案 5 :(得分:1)

我已成功(而且非常轻松地)使用https://github.com/nathanvda/cocoon来动态生成表单。优雅地处理关联,文档非常简单。您也可以将它与simple_form一起使用,这对我来说特别有用。