diff --git a/.gitignore b/.gitignore index fa4ca05..25d51bd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ spec/dummy/tmp/ .idea coverage/ Gemfile.lock +.sass-cache/ diff --git a/Gemfile b/Gemfile index e857791..bfbfb17 100644 --- a/Gemfile +++ b/Gemfile @@ -12,8 +12,9 @@ group :development, :test do gem "simplecov", :require => false gem "generator_spec" + gem "formtastic" end # To use debugger (ruby-debug for Ruby 1.8.7+, ruby-debug19 for Ruby 1.9.2+) # gem 'ruby-debug' -# gem 'ruby-debug19' \ No newline at end of file +# gem 'ruby-debug19' diff --git a/README.markdown b/README.markdown index 3a798e6..44e8365 100644 --- a/README.markdown +++ b/README.markdown @@ -16,6 +16,8 @@ This project is not related to [Apache Cocoon](http://cocoon.apache.org/) This gem uses jQuery, it is most useful to use this gem in a rails3 project where you are already using jQuery. +Sortable form support requires jQuery UI. + Furthermore i would advice you to use either formtastic or simple_form. I have a sample project where I demonstrate the use of cocoon with formtastic. @@ -37,6 +39,13 @@ asset_pipeline //= require cocoon ```` +If you also want to be able to sort nested forms, ordering them on a particular field, add `cocoon/ordered`: + +``` ruby +//= require cocoon +//= require cocoon/ordered +``` + ### Rails 3.0.x If you are using Rails 3.0.x, you need to run the installation task (since rails 3.1 this is no longer needed): @@ -125,6 +134,53 @@ That is all there is to it! There is an example project on github implementing it called [cocoon_formtastic_demo](https://github.com/nathanvda/cocoon_formtastic_demo). +Or, you can use the Formtastic `cocoon` field type to wrap up much of the boilerplate of the wrapper and +add association button: + +``` haml += f.inputs do + = f.input :name + = f.input :description + %h3 Tasks + #tasks + = f.input :tasks, :as => :cocoon + = f.actions do + = f.action :submit +``` + +#### Sortable forms + +Say you have a set of nested models that are ordered arbitrarily: + +``` ruby +class Task < ActiveRecord::Base + belongs_to :project + + default_scope :order => 'order ASC' +end +``` + +You want users to be able to sort those +models via the UI. You can do this by including `cocoon/ordered` and specifying the sort field in the Formtastic +input call: + +``` haml += f.input :tasks, :as => :cocoon, :ordered_by => :order +``` + +Add the order field as a hidden field in the nested form: + +``` haml +.nested-fields + = f.inputs do + = f.input :description + = f.input :done, :as => :boolean + = f.input :order, :as => :hidden + = link_to_remove_association "remove task", f +``` + +The order field will now be filled in correctly when new models are added and when the models are sorted. + ### Using simple_form Inside our `projects/_form` partial we then write: diff --git a/Rakefile b/Rakefile index 8b35f85..a09d6cb 100644 --- a/Rakefile +++ b/Rakefile @@ -39,4 +39,4 @@ begin Jeweler::GemcutterTasks.new rescue LoadError puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler" -end \ No newline at end of file +end diff --git a/app/assets/javascripts/cocoon.js b/app/assets/javascripts/cocoon.js index b4ec934..b45c389 100644 --- a/app/assets/javascripts/cocoon.js +++ b/app/assets/javascripts/cocoon.js @@ -7,6 +7,9 @@ content.replace(reg_exp, with_str); } + $.fn.parentSiblings = function(selector) { + return $(this).parent().siblings(selector); + } $('.add_fields').live('click', function(e) { e.preventDefault(); @@ -67,7 +70,8 @@ var timeout = trigger_node.data('remove-timeout') || 0; - setTimeout(function() { + setTimeout( + function() { if ($this.hasClass('dynamic')) { $this.closest(".nested-fields").remove(); } else { diff --git a/app/assets/javascripts/cocoon/ordered.js b/app/assets/javascripts/cocoon/ordered.js new file mode 100644 index 0000000..f3e1964 --- /dev/null +++ b/app/assets/javascripts/cocoon/ordered.js @@ -0,0 +1,88 @@ +//= require jquery-ui +// +(function($) { + $.cocoon = { + ordered: { + options: { + items: '> .nested-fields', + stop: function(e, ui) { + if (window.CKEDITOR) { + var editors = $(ui.item).data('cocoon_ckeditors'); + var i, j; + + for (i = 0, j = editors.length; i < j; ++i) { + var id = editors[i]; + var editor = CKEDITOR.instances[id]; + + if (editor) { + editor.destroy(true); + CKEDITOR.remove(id); + } + + CKEDITOR.replace(id); + } + + $(ui.item).data('cocoon_ckeditors', []); + } + + $.cocoon.ordered._updateFields(this) + }, + start: function(e, ui) { + if (window.CKEDITOR) { + var editors = []; + + $(ui.item).find('textarea').each(function(index, element) { + var id = $(element).attr('id'); + var editor = CKEDITOR.instances[id]; + if (editor) { + editors.push(id); + + editor.destroy(); + CKEDITOR.remove(id); + } + }); + + $(ui.item).data('cocoon_ckeditors', editors); + } + } + }, + _updateFields: function(element) { + console.log(element) + console.log($(element).data('fieldSearch')) + console.log($(element).find($(element).data('fieldSearch'))) + + $(element).find($(element).data('fieldSearch')).each(function(index, element) { + $(element).val(index + 1); + }); + }, + setup: function() { + $('li[data-ordered_by]').each(function(index, element) { + var field = $(element).data('ordered_by'); + var fieldSelector = "[name*='[" + field + "]']" + var fieldGroupSelector = "> .forms > .nested-fields" + var orderFieldSelector = "> .nested-fields " + fieldSelector; + var fieldSearch = "> .forms " + orderFieldSelector; + + $(element).find('.forms').data('fieldSearch', orderFieldSelector).sortable($.cocoon.ordered.options); + + $(element).unbind('cocoon:after-insert').bind('cocoon:after-insert', function(e, node) { + var nextOrder = 0; + + if ($(element).find(fieldGroupSelector).is(node)) { + $(element).find(fieldSearch).each(function() { + nextOrder = Math.max(nextOrder, Number($(this).val())); + }); + + $(node).find(fieldSelector).val(nextOrder + 1) + } + }); + }); + + $(document).on('cocoon:after-insert', function() { $.cocoon.ordered.setup(); }); + } + }, + }; + + $(function() { $.cocoon.ordered.setup(); }); +})(jQuery); + diff --git a/app/assets/stylesheets/cocoon/active_admin.css.scss b/app/assets/stylesheets/cocoon/active_admin.css.scss new file mode 100644 index 0000000..e685d2a --- /dev/null +++ b/app/assets/stylesheets/cocoon/active_admin.css.scss @@ -0,0 +1,29 @@ +li.cocoon { + & > label { + display: block !important; + width: 100% !important; + float: none !important; + } + + ol { + padding: 0 !important; + width: auto !important; + float: none !important; + } + + fieldset { + padding: 1em !important; + } + + .links a { + @extend .button; + } + + .forms.ui-sortable { + fieldset { + border-top: solid #777 12px; + cursor: move; + } + } +} + diff --git a/lib/cocoon.rb b/lib/cocoon.rb index ec9039c..3570373 100644 --- a/lib/cocoon.rb +++ b/lib/cocoon.rb @@ -10,7 +10,11 @@ module Cocoon # configure our plugin on boot initializer "cocoon.initialize" do |app| ActionView::Base.send :include, Cocoon::ViewHelpers + + if Object.const_defined?("Formtastic") and Formtastic.const_defined?("Inputs") + require 'cocoon/formtastic/cocoon_input' + end end end -end \ No newline at end of file +end diff --git a/lib/cocoon/formtastic/cocoon_input.rb b/lib/cocoon/formtastic/cocoon_input.rb new file mode 100644 index 0000000..e9160d7 --- /dev/null +++ b/lib/cocoon/formtastic/cocoon_input.rb @@ -0,0 +1,52 @@ +require 'formtastic' + +class CocoonInput + include ::Formtastic::Inputs::Base + + def to_html + output = label_html << wrapped_semantic_fields << links + + template.content_tag(:li, output.html_safe, wrapper_html_options) + end + + def wrapper_html_options + data = super.merge(:class => 'input cocoon') + if options[:ordered_by] + data['data-ordered_by'] = options[:ordered_by] + end + + data + end + + def semantic_fields_for + builder.semantic_fields_for(method) do |fields| + if fields.object + template.render :partial => "#{singular_method}_fields", :locals => { :f => fields } + end + end + end + + def wrapped_semantic_fields + template.content_tag(:div, semantic_fields_for, :class => 'forms') + end + + def links + template.content_tag(:div, :class => 'links') do + template.link_to_add_association template.t(".add_#{singular_method}"), builder, method, input_html_options + end + end + + def input_html_options + super.merge( + 'data-association-insertion-node' => '.forms', + 'data-association-insertion-traversal' => 'parentSiblings', + 'data-association-insertion-method' => 'append' + ) + end + + private + def singular_method + @singular_method ||= method.to_s.singularize + end +end + diff --git a/lib/cocoon/view_helpers.rb b/lib/cocoon/view_helpers.rb index f83a207..3309aad 100644 --- a/lib/cocoon/view_helpers.rb +++ b/lib/cocoon/view_helpers.rb @@ -35,6 +35,7 @@ module Cocoon partial = get_partial_path(custom_partial, association) locals = render_options.delete(:locals) || {} method_name = f.respond_to?(:semantic_fields_for) ? :semantic_fields_for : (f.respond_to?(:simple_fields_for) ? :simple_fields_for : :fields_for) + f.send(method_name, association, new_object, {:child_index => "new_#{association}"}.merge(render_options)) do |builder| partial_options = {:f => builder, :dynamic => true}.merge(locals) render(partial, partial_options) @@ -99,6 +100,14 @@ module Cocoon partial ? partial : association.to_s.singularize + "_fields" end + def cocoon_wrapper(form_builder, &block) + content_tag(:div, :class => 'nested-fields') do + content_tag(:fieldset) do + capture(&block) << link_to_remove_association(t('.remove'), form_builder) + end + end + end + private def create_object_on_non_association(f, association) diff --git a/spec/formtastic/cocoon_input_spec.rb b/spec/formtastic/cocoon_input_spec.rb new file mode 100644 index 0000000..e85a859 --- /dev/null +++ b/spec/formtastic/cocoon_input_spec.rb @@ -0,0 +1,73 @@ +require 'spec_helper' +require 'cocoon/formtastic/cocoon_input' + +describe CocoonInput do + let(:input) { CocoonInput.new(builder, template, object, object_name, method, options) } + let(:builder) { stub(:auto_index => false, :options => {}, :custom_namespace => nil, :all_fields_required_by_default => false) } + let(:template) { stub } + let(:object) { stub } + let(:object_name) { :object } + let(:method) { :nested } + let(:options) { {} } + + describe '#wrapper_html_options' do + subject { input.wrapper_html_options } + + context 'not ordered' do + it 'should not be ordered' do + subject.should_not have_key('data-ordered_by') + end + end + + context 'ordered' do + let(:field) { :field } + let(:options) { { :ordered_by => field } } + + it 'should be ordered' do + subject['data-ordered_by'].should == field + end + end + end + + describe '#links' do + subject { input.links } + + before do + template.stub(:content_tag).and_yield + template.stub(:link_to_add_association) + template.stub(:t) + end + + it 'should generate the links holder' do + subject + end + end + + describe '#semantic_fields_for' do + subject { input.semantic_fields_for } + + before do + builder.stub(:semantic_fields_for) + template.stub(:render) + end + + it 'should pass through to semantic_fields_for on the builder' do + subject + end + end + + describe '#to_html' do + subject { input.to_html } + + before do + input.stub(:label_html).and_return('label') + input.stub(:wrapped_semantic_fields).and_return('fields') + input.stub(:links).and_return('links') + template.stub(:content_tag) + end + + it 'should concatenate the outputs and pass through to content_tag' do + subject + end + end +end