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

Posted

技术标签:

【中文标题】使用 jQuery 在 Rails 中不显眼的动态表单字段【英文标题】:Unobtrusive dynamic form fields in Rails with jQuery 【发布时间】:2010-12-14 19:38:52 【问题描述】:

我正在尝试克服 Rails 中动态表单字段的障碍——这似乎是框架不能很好地处理的问题。我也在我的项目中使用 jQuery。我已经安装了 jRails,但我更愿意尽可能不显眼地编写 AJAX 代码。

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

到目前为止,我看到的每个示例都以某种方式丑陋。 Ryan Bates 的tutorial 需要 RJS,这会导致标记中出现一些非常难看的突兀 javascript,并且似乎是在嵌套属性之前编写的。我已经看到 fork 使用不显眼的 jQuery 的该示例,但我只是不明白它在做什么,并且无法让它在我的项目中工作。

有人可以提供一个简单的例子来说明这是如何完成的吗?在尊重控制器的 RESTful 约定的同时,这是否可能?


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

【问题讨论】:

您是否在编写不显眼的 ajaxified jQuery 时遇到问题,或者它是否特定于嵌套表单?如果您想不显眼,那么摆脱 jRails 是一个好的开始。 我需要能够支持嵌套表单,但我现在正在努力掌握基础知识。我安装了 jRails,但我没有使用它;就像我说的那样,我想以不显眼的方式来做,当我非常擅长 javascript 时,我宁愿不必学习另一个 DSL。 【参考方案1】:

由于没有人对此提供答案,即使在赏金之后,我终于设法自己解决了这个问题。这不应该是一个笨蛋!希望这在 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非常简单,可以很容易地用其他框架来完成。例如,您可以轻松地将我的 application.js 代码替换为原型 + lowpro。基本思想是您不会将巨大的 javascript 函数嵌入到您的标记中,并且您不必在模型中编写乏味的 phone_numbers=() 函数。一切正常。万岁!


经过进一步测试,我得出的结论是模板需要从&lt;form&gt; 字段中移出。将它们保留在那里意味着它们会与表单的其余部分一起被发送回服务器,而这只会在以后造成麻烦。

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

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

并修改了new_child_fields_template helper:

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 子句,而不必担心模板会被发回。

【讨论】:

很棒的答案。我唯一调整的是将删除链接稍微移动一点,以便我可以 .remove() 字段而不是 .hide()。这样我就可以使用 Cucumber + Selenium 来确保它们消失了。非常感谢! @matschaffer 我实际上已将其更改为使用 Mustache.js 模板,而不是将它们存储在 DOM 中。如果您的前端如此复杂,我会鼓励您研究一下。 这是一种态度,花点时间回答让大家知道,非常感谢Adam! @Ramon 不幸的是,这已经完全过时了 :-/ 其中一天我需要更新它以解释如何使用模板。 @DGM 是的,我现在就是这样做的。【参考方案2】:

根据您对我的评论的回答,我认为不显眼地处理删除是一个不错的起点。我将使用带脚手架的 Product 作为示例,但代码将是通用的,因此它应该易于在您的应用程序中使用。

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

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 感兴趣。

【讨论】:

这很有帮助,谢谢。您能否发布一个如何创建新表单字段的示例? 有一个使用 GET 的“删除”路由?这不是自找麻烦吗? 您可以随意调用它(例如,在我的一个应用程序中,它称为“confirm_delete”),GET 请求不会执行实际删除。关键是您有一个包含删除表单并要求用户确认删除的视图。【参考方案3】:

顺便说一句。 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;
  );
);

【讨论】:

很好,看看我的代码库,我实际上是自己做了这个更改,但忘记更新帖子了。【参考方案4】:

仅供参考,Ryan Bates 现在拥有一个运行良好的宝石:nested_form

【讨论】:

这个 gem 还在维护吗?我的意思是实际使用安全吗?我看到Latest commit 1b0689d on Dec 1, 2013。如果没有,除了茧还有其他选择吗?【参考方案5】:

我创建了一个不显眼的 jQuery 插件,用于为 Rails 3 动态添加 fields_for 对象。它非常易于使用,只需下载 js 文件。几乎没有配置。只需遵循约定,您就可以开始了。

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

它不是超级灵活,但可以胜任。

【讨论】:

我有一个与您的众多.js 插件相关的问题。我在这里发布它是因为这是向您发出信号的最简单方法,也许其他人可能会遇到同样的问题并关注这个问题。 ***.com/q/13783927/439756【参考方案6】:

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

【讨论】:

以上是关于使用 jQuery 在 Rails 中不显眼的动态表单字段的主要内容,如果未能解决你的问题,请参考以下文章

Rails - Ajax 与不显眼的 javascript Jquery UI 进度条

物化 jQuery 在 Rails 6 中不起作用

jQuery准备在Rails 3中不起作用

Rails 日期/时间选择器(希望是 jquery)[关闭]

表单未在 Rails 中使用不显眼的 javascript 提交

jquery ui datepicker在我的rails应用程序中不起作用