Permalink
Browse files

Add nested:removed:field event; add events for Prototype

  • Loading branch information...
1 parent 59d9c36 commit 421cd183f5e459bee344be2f8a64d4ff1435b107 @nashbridges nashbridges committed Aug 10, 2012
View
@@ -64,10 +64,50 @@ It is often desirable to move the nested fields into a partial to keep things or
In this case it will look for a partial called "task_fields" and pass the form builder as an +f+ variable to it.
-== Events
-
-If you are using jQuery, <tt>nested:fieldAdded</tt> and <tt>nested:fieldRemoved</tt> events are triggered on the +form+ element after adding and removing fields.
-
+== JavaScript events
+
+Sometimes you want to do some additional work after element was added or removed, but only
+after DOM was _really_ modified. In this case simply listening for click events on
+'Add new'/'Remove' link won't reliably work, because your code and code that inserts/removes
+nested field will run concurrently.
+
+This problem can be solved, because after adding or removing the field a set of custom events
+is triggered on this field. Using form example from above, if you click on the "Add a task" link,
+<tt>nested:fieldAdded</tt> and <tt>nested:fieldAdded:tasks</tt> will be triggered, while
+<tt>nested:fieldRemoved</tt> and <tt>nested:fieldRemoved:tasks</tt> will be triggered if you click
+"Remove this task" then.
+
+These events bubble up the DOM tree, going through +form+ element, until they reach the +document+.
+This allows you to listen for the event and trigger some action accordingly. Field element, upon
+which action was made, is passed along with the +event+ object. In jQuery you can access it
+via +event.field+, in Prototype the same field will be in +event.memo.field+.
+
+For example, you have a date input in a nested field and you want to use jQuery datepicker
+for it. This is a bit tricky, because you have to activate datepicker after field was inserted.
+
+=== jQuery
+
+ $(document).on('nested:fieldAdded', function(event){
+ // this field was just inserted into your form
+ var field = event.field;
+ // it's a jQuery object already! Now you can find date input
+ var dateField = field.find('.date');
+ // and activate datepicker on it
+ dateField.datepicker();
+ })
+
+=== Prototype
+
+ document.observe('nested:fieldAdded', function(event){
+ var field = event.memo.field;
+ // it's already extended by Prototype
+ var dateField = field.down('.date');
+ dateField.datepicker();
+ })
+
+Second type of event (i.e. <tt>nested:fieldAdded:tasks</tt>) is useful then you have more than one type
+of nested fields on a form (i.e. tasks and milestones) and want to distinguish, which exactly
+was added/deleted.
== Enhanced jQuery JavaScript template
@@ -42,6 +42,12 @@ def link_to_add(*args, &block)
def link_to_remove(*args, &block)
options = args.extract_options!.symbolize_keys
options[:class] = [options[:class], "remove_nested_fields"].compact.join(" ")
+
+ if parent_builder
+ association = object.class.name.demodulize.downcase.pluralize
+ options["data-association"] = association
+ end
+
args << (options.delete(:href) || "javascript:void(0)")
args << options
(hidden_field(:_destroy) << @template.link_to(*args, &block)).html_safe
@@ -0,0 +1,19 @@
+$(function() {
+ var log = function(text) {
+ $('<p/>', {text: text}).appendTo('#console');
+ };
+
+ ['Added', 'Removed'].forEach(function(action) {
+ $(document).on('nested:field' + action, function(e) {
+ log(action + ' some field')
+ });
+
+ $(document).on('nested:field' + action + ':tasks', function(e) {
+ log(action + ' task field')
+ });
+
+ $(document).on('nested:field' + action + ':milestones', function(e) {
+ log(action + ' milestone field')
+ });
+ });
+});
@@ -0,0 +1,20 @@
+document.observe('dom:loaded', function() {
+ var log = function(text) {
+ var p = new Element('p').update(text);
+ $('console').insert(p);
+ };
+
+ ['Added', 'Removed'].forEach(function(action) {
+ document.observe('nested:field' + action, function(e) {
+ log(action + ' some field')
+ });
+
+ document.observe('nested:field' + action + ':tasks', function(e) {
+ log(action + ' task field')
+ });
+
+ document.observe('nested:field' + action + ':milestones', function(e) {
+ log(action + ' milestone field')
+ });
+ });
+});
@@ -2,10 +2,18 @@
<html>
<head>
<title>Dummy</title>
+
+ <% if params[:type] == 'prototype' %>
+ <%= javascript_include_tag 'prototype', 'prototype_nested_form', 'prototype_events_test' %>
+ <% else %>
+ <%= javascript_include_tag 'jquery', 'jquery_nested_form', 'jquery_events_test' %>
+ <% end %>
</head>
<body>
<%= yield %>
+<div id="console"></div>
+
</body>
</html>
@@ -1,9 +1,3 @@
-<% if params[:type] == 'prototype' %>
- <%= javascript_include_tag 'prototype', 'prototype_nested_form' %>
-<% else %>
- <%= javascript_include_tag 'jquery', 'jquery_nested_form' %>
-<% end %>
-
<%= nested_form_for @project do |f| -%>
<%= f.text_field :name %>
<%= f.fields_for :tasks do |tf| -%>
View
@@ -0,0 +1,60 @@
+require 'spec_helper'
+
+describe 'Nested form', :js => true do
+ include Capybara::DSL
+
+ [:jquery, :prototype].each do |js_framework|
+
+ url = case js_framework
+ when :jquery then '/projects/new'
+ when :prototype then '/projects/new?type=prototype'
+ end
+
+ context "with #{js_framework}" do
+ context 'when field was added' do
+ it 'emits general add event' do
+ visit url
+ click_link 'Add new task'
+
+ page.should have_content 'Added some field'
+ end
+
+ it 'emits add event for current association' do
+ visit url
+ click_link 'Add new task'
+
+ page.should have_content 'Added task field'
+ page.should_not have_content 'Added milestone field'
+
+ click_link 'Add new milestone'
+
+ page.should have_content 'Added milestone field'
+ end
+ end
+
+ context 'when field was removed' do
+ it 'emits general remove event' do
+ visit url
+ click_link 'Add new task'
+ click_link 'Remove'
+
+ page.should have_content 'Removed some field'
+ end
+
+ it 'emits remove event for current association' do
+ visit url
+ 2.times { click_link 'Add new task' }
+ click_link 'Remove'
+
+ page.should have_content 'Removed task field'
+ page.should_not have_content 'Removed milestone field'
+
+ click_link 'Add new milestone'
+ click_link 'Remove milestone'
+
+ page.should have_content 'Removed milestone field'
+ end
+ end
+ end
+ end
+end
@@ -22,6 +22,13 @@
@builder.link_to_remove { "Remove" }.should == '<input id="item__destroy" name="item[_destroy]" type="hidden" value="false" /><a href="javascript:void(0)" class="remove_nested_fields">Remove</a>'
end
+ it 'adds data-association attribute to the remove link' do
+ @project.tasks.build
+ @builder.fields_for(:tasks, :builder => builder) do |tf|
+ tf.link_to_remove 'Remove'
+ end.should match '<a.+data-association="tasks">Remove</a>'
+ end
+
it "should wrap nested fields each in a div with class" do
2.times { @project.tasks.build }
@builder.fields_for(:tasks) do
@@ -42,7 +42,8 @@ jQuery(function($) {
content = content.replace(regexp, "new_" + new_id);
var field = this.insertFields(content, assoc, link);
- $(link).closest("form")
+ // bubble up event upto document (through form)
+ field
.trigger({ type: 'nested:fieldAdded', field: field })
.trigger({ type: 'nested:fieldAdded:' + assoc, field: field });
return false;
@@ -51,16 +52,18 @@ jQuery(function($) {
return $(content).insertBefore(link);
},
removeFields: function(e) {
- var link = e.currentTarget;
- var hiddenField = $(link).prev('input[type=hidden]');
+ var $link = $(e.currentTarget),
+ assoc = $link.data('association'); // Name of child to be removed
+
+ var hiddenField = $link.prev('input[type=hidden]');
hiddenField.val('1');
- // if (hiddenField) {
- // $(link).v
- // hiddenField.value = '1';
- // }
- var field = $(link).closest('.fields');
+
+ var field = $link.closest('.fields');
field.hide();
- $(link).closest("form").trigger({ type: 'nested:fieldRemoved', field: field });
+
+ field
+ .trigger({ type: 'nested:fieldRemoved', field: field })
+ .trigger({ type: 'nested:fieldRemoved:' + assoc, field: field });
return false;
}
};
@@ -1,22 +1,22 @@
document.observe('click', function(e, el) {
- if (el = e.findElement('form a.add_nested_fields')) {
- // Setup
- var assoc = el.readAttribute('data-association'); // Name of child
- var content = $(assoc + '_fields_blueprint').innerHTML; // Fields template
+ if (el = e.findElement('form a.add_nested_fields')) {
+ // Setup
+ var assoc = el.readAttribute('data-association'); // Name of child
+ var content = $(assoc + '_fields_blueprint').innerHTML; // Fields template
- // Make the context correct by replacing new_<parents> with the generated ID
- // of each of the parent objects
- var context = (el.getOffsetParent('.fields').firstDescendant().readAttribute('name') || '').replace(new RegExp('\[[a-z]+\]$'), '');
+ // Make the context correct by replacing new_<parents> with the generated ID
+ // of each of the parent objects
+ var context = (el.getOffsetParent('.fields').firstDescendant().readAttribute('name') || '').replace(new RegExp('\[[a-z]+\]$'), '');
- // context will be something like this for a brand new form:
- // project[tasks_attributes][new_1255929127459][assignments_attributes][new_1255929128105]
- // or for an edit form:
- // project[tasks_attributes][0][assignments_attributes][1]
- if(context) {
- var parent_names = context.match(/[a-z_]+_attributes/g) || [];
- var parent_ids = context.match(/(new_)?[0-9]+/g) || [];
+ // context will be something like this for a brand new form:
+ // project[tasks_attributes][new_1255929127459][assignments_attributes][new_1255929128105]
+ // or for an edit form:
+ // project[tasks_attributes][0][assignments_attributes][1]
+ if(context) {
+ var parent_names = context.match(/[a-z_]+_attributes/g) || [];
+ var parent_ids = context.match(/(new_)?[0-9]+/g) || [];
- for(i = 0; i < parent_names.length; i++) {
+ for(i = 0; i < parent_names.length; i++) {
if(parent_ids[i]) {
content = content.replace(
new RegExp('(_' + parent_names[i] + ')_.+?_', 'g'),
@@ -26,26 +26,31 @@ document.observe('click', function(e, el) {
new RegExp('(\\[' + parent_names[i] + '\\])\\[.+?\\]', 'g'),
'$1[' + parent_ids[i] + ']');
}
- }
- }
+ }
+ }
- // Make a unique ID for the new child
- var regexp = new RegExp('new_' + assoc, 'g');
- var new_id = new Date().getTime();
- content = content.replace(regexp, "new_" + new_id);
+ // Make a unique ID for the new child
+ var regexp = new RegExp('new_' + assoc, 'g');
+ var new_id = new Date().getTime();
+ content = content.replace(regexp, "new_" + new_id);
- el.insert({ before: content });
- return false;
- }
+ var field = el.insert({ before: content });
+ field.fire('nested:fieldAdded', {field: field});
+ field.fire('nested:fieldAdded:' + assoc, {field: field});
+ return false;
+ }
});
document.observe('click', function(e, el) {
- if (el = e.findElement('form a.remove_nested_fields')) {
- var hidden_field = el.previous(0);
- if(hidden_field) {
- hidden_field.value = '1';
- }
- el.up('.fields').hide();
- return false;
- }
+ if (el = e.findElement('form a.remove_nested_fields')) {
+ var hidden_field = el.previous(0),
+ assoc = el.readAttribute('data-association'); // Name of child to be removed
+ if(hidden_field) {
+ hidden_field.value = '1';
+ }
+ var field = el.up('.fields').hide();
+ field.fire('nested:fieldRemoved', {field: field});
+ field.fire('nested:fieldRemoved:' + assoc, {field: field});
+ return false;
+ }
});

0 comments on commit 421cd18

Please sign in to comment.