-
Notifications
You must be signed in to change notification settings - Fork 184
How to: create dynamic nested forms like Cocoon gem using Cells
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.
- 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).
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
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;
}
}
}
}
}
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'
});
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
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?' }])
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