Skip to content

How to: create dynamic nested forms like Cocoon gem using Cells

Annie Caron edited this page Jun 23, 2016 · 11 revisions

How to: create dynamic nested forms like Cocoon gem using Cells

What it can do?

You can dynamically create a new entry for a nested model using JavaScript. It supports serialized field or associations using ids. You can add, remove and sort (optional) all entries.

Screenshots

Nested form

Sorting nested form

Prerequisites

  • Ruby on Rails (this is Rails specific for the fields creation. Can be easily change!);
  • Trailblazer (I use Trailblazer with the concepts folder);
  • jQuery;
  • jQuery UI Sortable;
  • Underscore JS;
  • UIkit CSS framework with Almost flat theme (optional, this is the one I will use in my demo).

Steps

1. Add a cell to manage nested form

Here is my cell architecture:

concepts
-- layout
---- form
------ nested
-------- views
---------- row.haml
---------- show.haml
-------- cell.rb

I have layout-specific cells wrapped in a layout concept. You can put it wherever you like.

Here is the important classes use for the JavaScript (it is fully customisable in the plugin call):

.nested-form-attribute (global wrapper)
  .nested-form-wrapper (rows wrapper)
    .nested-form-row (row)
      .nested-form-sort-handle (optional, sort handle)
      .nested-form-remove-row (remove button)
  .nested-form-add-row (add button)

row.haml: Simply wrap the row inside a div, add the sort handle if it is sortable, add fields and add a remove button.

%div.nested-form-row.uk-margin-small-top
  - if sortable
    %div.nested-form-sort-handle
      %i.uk-icon-reorder
  = fields
  %a.nested-form-remove-row.uk-button.uk-button-danger{ title: 'Remove', 'data-uk-tooltip': '' }
    %i.uk-icon-minus

show.haml: Add a label, add a wrapper for all rows, add an add button and create the row template.

%div.nested-form-attribute.uk-form-row.uk-margin-small-top{ 'data-name': attribute_name }
  %label.uk-form-label= label
  %div.uk-form-controls
    %div.nested-form-wrapper
      = form.fields_for :"#{attribute_name}", collection do |nested_form|
        = row(nested_form)
    %a.nested-form-add-row.uk-button.uk-button-success.uk-margin-small-top{ title: 'Add', 'data-uk-tooltip': '' }
      %i.uk-icon-plus

%script{ type: 'text/template', id: "nested-form-template-#{attribute_name}" }
  = row

cell.rb: Generate the nested form. Actually, it supports three types of field: boolean, collection and string.

module Layout::Form
  class Nested::Cell < Cell::Concept
    include ActionView::Helpers::FormOptionsHelper

    # === Parameters
    # {
    #   form: instance,
    #   attribute_name: :symbol,
    #   label: 'string',
    #   fields: [{ type: :(string|collection|boolean), name: :symbol }],
    #   sortable: (true|false)
    # }
    def show
      options[:sortable] = true unless options.key?(:sortable)

      render locals: options.except(:fields)
    end

    def collection
      @collection ||= @model
                      .send(options[:attribute_name])
                      .try(:order_by_nested)
    end

    def row(nested_form = nil)
      @nested_form = nested_form

      render view: :row, locals: options.except(:fields)
    end

    def fields
      html = field_id("#{field_name}[id]")
      html += field_hidden("#{field_name}[_destroy]", '0')

      options[:fields].each do |field|
        html += generate(field)
      end

      html
    end

    private

    def generate(field)
      send(
        "field_#{field[:type]}",
        "#{field_name}[#{field[:name]}]",
        field_value(field[:name]),
        field
      )
    end

    def object
      @nested_form.object if @nested_form
    end

    def object_name
      options[:form].object_name
    end

    def field_name
      "#{object_name}[#{options[:attribute_name]}_attributes][]"
    end

    def field_value(name)
      if field_attributes
        field_attributes[name]
      else
        object ? object.send(name) : nil
      end
    end

    def field_attributes
      return unless params.key?(object_name) && @nested_form

      params[object_name][options[:attribute_name]][@nested_form.index]
    end

    def field_id(name)
      if object && object.try(:id)
        @nested_form.hidden_field(:id, name: name)
      else
        hidden_field_tag(name)
      end
    end

    def field_boolean(name, value, options = {})
      label_tag(nil, check_box_tag(name, '1', value) + " #{options[:label]}")
    end

    def field_collection(name, value, options = {})
      select_tag(
        name,
        options_for_select(options[:collection], value),
        class: 'uk-form-width-medium'
      )
    end

    def field_hidden(name, value, _options = {})
      hidden_field_tag(name, value)
    end

    def field_string(name, value, _options = {})
      text_field_tag(name, value, class: 'uk-form-width-medium')
    end
  end
end

2. Customize the look

Here is my LESS file for a complete exemple:

.nested-form-attribute {
  .nested-form-wrapper {
    .uk-placeholder {
      padding: 0;
      margin: 5px 0 0 0;
    }

    .nested-form-row {
      font-size: 0;

      .nested-form-sort-handle {
        background: #f5f5f5;
        border: 1px solid rgba(0, 0, 0, 0.06);
        padding: 0;
        display: inline-block;
        text-align: center;
        height: 28px;
        line-height: 28px;
        width: 30px;
        vertical-align: middle;
        cursor: move;
      }

      .nested-form-remove-row {
        width: 36px;
      }

      > * {
        margin-right: 5px;
        font-size: 13px;

        &:last-child {
          margin-right: 0;
        }
      }
    }
  }
}

3. Add some JavaScript

nested-form.js: A simple plugin I created. See the defaults at the bottom for available options!

// ---------------------------------
// ---------- Nested Form Plugin ----------
// ---------------------------------
// Brief plugin description
// Using John Dugan's boilerplate: https://john-dugan.com/jquery-plugin-boilerplate-explained/
// ------------------------

;(function ( $, window, document, undefined ) {
  var pluginName = 'nestedForm';

  // Create the plugin constructor
  function Plugin ( element, options ) {
    this.element = element;

    this._name = pluginName;
    this._defaults = $.fn.nestedForm.defaults;

    this.options = $.extend( {}, this._defaults, options );

    this.init();
  }

  // Avoid Plugin.prototype conflicts
  $.extend(Plugin.prototype, {

    // Initialization logic
    init: function () {
      this.build();
      this.bindEvents();
      this.applySort();
    },

    // Remove plugin instance completely
    destroy: function() {
      this.unbindEvents();
      this.$element.removeData();
    },

    // Cache DOM nodes for performance
    build: function () {
      var plugin = this;

      plugin.$element = $(plugin.element);

      $.each(plugin._defaults, function(key, value) {
        option = plugin.$element.data(key);

        if (option !== undefined) {
          plugin.options[key] = option;
        }
      });

      plugin.$objects = {
        nested_wrapper: plugin.$element.find(this.options.wrapper),
        template: $('#nested-form-template-' + plugin.options.name).html()
      };
    },

    // Bind events that trigger methods
    bindEvents: function() {
      var plugin = this,
          event_click = 'click' + '.' + plugin._name;

      plugin.$element.on(event_click, this.options.addRow, function(event) {
        event.preventDefault();

        plugin.addRow.call(plugin);
      });

      plugin.$objects.nested_wrapper.on(event_click, this.options.removeRow, function(event) {
        event.preventDefault();

        plugin.removeRow.call(plugin, this);
      });
    },

    // Unbind events that trigger methods
    unbindEvents: function() {
      this.$element.off('.'+this._name);
    },

    // Apply jQuery UI sortable
    applySort: function() {
      this.$objects.nested_wrapper.sortable({
        items: this.options.row,
        axis: 'y',
        handle: this.options.sortHandle,
        opacity: 0.4,
        scroll: true,
        placeholder: 'uk-placeholder',
        start: function(event, ui) {
          ui.placeholder.height(ui.helper.outerHeight() - 2);

          elements = ui.helper.find('> *:visible');
          width = (elements.length - 1) * 5;
          $.each(elements, function(index, item) {
            width += $(item).outerWidth();
          });

          ui.placeholder.width(width - 2);
        }
      });
    },

    // Add a new row
    addRow: function() {
      var $template = $(_.template(this.$objects.template)());

      this.$objects.nested_wrapper.append($template);
    },

    // Remove row
    removeRow: function(element) {
      var $nestedRow = $(element).parent(this.options.row),
          $id = $nestedRow.find('[name*="[id]"]');

      if ($id.val() === '') {
        $nestedRow.remove();
      } else {
        $nestedRow.hide();
      }

      $nestedRow.find('[name*="[_destroy]"]').val('1');
    }

  });

  $.fn.nestedForm = function ( options ) {
    this.each(function() {
      if ( !$.data( this, 'plugin_' + pluginName ) ) {
        $.data( this, 'plugin_' + pluginName, new Plugin( this, options ) );
      }
    });

    return this;
  };

  $.fn.nestedForm.defaults = {
    name: '',
    wrapper: '.nested-form-wrapper',
    sortHandle: '.nested-form-sort-handle',
    row: '.nested-form-row',
    addRow: '.nested-form-add-row',
    removeRow: '.nested-form-remove-row'
  };

})( jQuery, window, document );

Now, call the plugin (the name must be pass to work properly!):

With data-attributes:

%div.nested-form-attribute{ 'data-name': attribute_name }

$('.nested-form-attribute').nestedForm();

OR

With options:

$('.my-attribute-name').nestedForm({
  name: 'my-attribute-name'
});

4. Some processing to do on the backend side

I added a module in Trailblazer::Operation that I can include when I need NestedField. In my JavaScript, I decide not to manage the position and just loop inside the given array to add them. If you want to manage them in the JavaScript, you can try angular double-binding or else. Feel free to do what you like! I have two functions for sanitization: one for associations and one for serialized field.

Also, I added a function to manage the _destroy parameters for associations. It exists in Ruby On Rails only so I added the feature for Reform.

Note: You can put this code where you want depending on your architecture. For example, you can put it in lib/traiblazer/operations/nested_field.rb and include this file in your autoload path (with Rails).

class Trailblazer::Operation
  module NestedField
    private

    def sanitize!(params, attribute)
      collection = {}
      index = 1
      params[:"#{attribute}_attributes"].each do |item|
        unless item[:_destroy].eql?('1')
          item[:position] = index
          index += 1
        end
        collection[index] = item unless item[:_destroy].eql?('1') && item[:id].blank?
      end if params.key?("#{attribute}_attributes")

      params[:"#{attribute}_attributes"] = collection
    end

    def serialize_sanitize!(params, attribute)
      collection = {}
      params[:"#{attribute}_attributes"].each_with_index do |item, index|
        return if item[:_destroy].eql?('1')

        item[:position] = index + 1
        collection[index] = item
      end if params.key?("#{attribute}_attributes")

      params[:"#{attribute}_attributes"] = collection

      model.send("#{attribute}=", nil)
    end

    def marked_for_destruction!(params, attribute)
      ids = params[:"#{attribute}_attributes"].values.map do |item|
        item['id'].to_i if item['_destroy'].eql?('1')
      end.compact

      model.send(attribute).where(id: ids).destroy_all
      contract.send(attribute).reject! {|item| ids.include?(item.id) }
    end
  end
end

Here is an example for a serialized field:

include NestedField
[...]
def process(params)
  serialize_sanitize!(params[:thing], :filters)

  return unless validate(params[:thing])

  contract.save
end

Here is an example for an association field: Create process:

include NestedField
[...]
def process(params)
  sanitize!(params[:thing], :resources)

  return unless validate(params[:thing])

  contract.save
end

Update process (the transaction is optional, but a good practice):

include NestedField
[...]
def process(params)
  sanitize!(params[:thing], :resources)

  return unless validate(params[:thing])

  ActiveRecord::Base.transaction do
    begin
      marked_for_destruction!(
        params[:thing],
        :resources
      )

      contract.save
    rescue ActiveRecord::Rollback
      invalid!
    end
  end
end

5. Add it to your view

An example with the three types of fields:

= concept('layout/form/nested/cell', @model, form: f, attribute_name: 'resources', label: 'Resources', fields: [{ type: :collection, name: :type, collection: @form.types_collection }, { type: :string, name: :value }, { type: :boolean, name: :required, label: 'Required?' }])

Extra: if you are using Reform, here is an example

With serialized field:

collection :filters, populate_if_empty: OpenStruct do
  property :position
  property :value
end

With association field:

collection :resources, skip_if: :skip_resources?, populate_if_empty: Resource do
  property :typeable_type
  property :value
  property :required
  property :path_needed
end

def skip_resources?(fragment, options)
  return fragment['value'].blank?
end