Anybody that already had to use SimpleForm knows that it's a powerfull tool for form building.
Unfortunaly when it comes to create your own inputs, there is a real lack of documentation. As a developper, I tend to prefer documented examples. So here I am, trying to give you a “as clean as possible” example for multy-entries input.
The screencapture is in french, so here is a short explanation of what it aims to do :
We are a training company, and as a training company we have training sessions. These sessions can have different pricing grids. A pricing grid is defined by entries : an entry is defined by a number/limit of trainees and an associated price.
Example : (grid 1)
- 1 trainee : cost = 1_000€
- from 3 to 6 trainees : cost per trainee = 900€
- from 7 : cost per trainee = 800€
Here is the custom input code :
app/inputs/pricing_grid_input.rb
# Custom input for pricing grid as bi-dimensional array
# Posting params will like the following :
#
# > "pricing_grid"=>{"0"=>["1", "1_099_00"], "1"=>["3", "999_00"], "5"=>… }
#
# Unfortunately multi-dimensional arrays cannot be built from forms.
#
# That's the reason why you'll have to convert pricing_grid params as a 2d array
# in your controller.
# Here is an example :
# ```
# if params[:course][:pricing_grid]
# params[:course][:pricing_grid] = params[:course][:pricing_grid].values
# end
# ```
# Because strong parameters won't accept multi-dimensional array, you'll have
# to manage it outside of strong parameters.
# ```
# grid_format = params[:course][:pricing_grid].try(:keys).inject({}) { |acc, k| acc[k] = []; acc }
# params.require(:course).permit(…, pricing_grid: grid_format)
# ```
#
class PricingGridInput < SimpleForm::Inputs::StringInput
def input(wrapper_options = nil)
input_html_options[:type] ||= input_type
classes = input_html_options.delete(:class)
values = object.public_send(attribute_name)
elements = if values.blank?
if 1 < (entry_nb = options[:entry_number].to_i)
entry_nb.collect { |i| [i, nil] }
else
[[1, nil]]
end
else
values
end
elements.each_with_index.map do |elem, index|
grid_entry(elem, index, classes)
end.join.html_safe + button_add
end
# Button used by JS in order to dynamically add a new entry
def button_add
template.content_tag(:button, class: 'js-btn-add btn') do
title = I18n.t('simple_form.actions.defaults.clone_row')
template.content_tag(:i, class: 'material-icons left', 'aria-hidden': true) { 'queue' } +
title
end
end
# Button used by JS in order to dynamically remove an entry
def button_delete
template.content_tag(:button, class: 'btn-floating btn-delete') do
title = I18n.t('simple_form.actions.defaults.clone_row')
template.content_tag(:i, class: 'material-icons', 'aria-hidden': true) { 'remove' }
end
end
def grid_entry(elem, counter, classes)
template.content_tag(:div, class: 'price-entry') do
content = single_entry(elem, classes, counter)
content += button_delete if counter > 0
content
end
end
# Display a single entry with two text fields : 'step', 'price'
def single_entry(elem, classes, counter, position = 0)
[:step, :price].each_with_index.map do |key, index|
template.content_tag(:div, class: ([classes] << key).flatten.join(' ')) do
name = "#{object_name}[#{attribute_name}][#{counter}][]"
value = elem[index + position]
# Use default values when needed
if value.blank? && options[:defaults] && object.new_record?
value = (options[:defaults] || []).flatten[index + position]
end
if :price == key && value
value = value / 100.0
end
after_label = I18n.t("simple_form.labels.pricing_grid.#{:step == key ? 'trainees' : 'euros'}")
component = @builder.label(name, I18n.t("simple_form.labels.pricing_grid.#{key}")) +
@builder.text_field(nil, input_html_options.merge(value: value, name: name)) +
template.content_tag(:span, class: 'after-label') { after_label.html_safe() }
component
end
end.join.html_safe
end
def input_type
:number
end
end
As we are using Sass and Materialize, here is our scss :
app/assets/stylesheets/…/components/pricing-grid-input.scss
.card.pricing-grid {
background-color: color('grey', 'lighten-3');
.status {
background-color: color('grey', 'darken-1');
color: white;
padding: 2px 10px;
margin-right: 1rem;
@include rounded(10px);
}
&.current {
background-color: white;
.status {
background-color: color('green', 'base');
}
}
&.expired .status {
background-color: color('red', 'base');
}
}
Finally, the Javascript that manages dynamic behaviors (please don't focus on code quality here ;o) ) :
- add a new entry
- manage remove buttons (delete entry)
app/assets/javascripts/…/pricing-grid.js
(function () {
var ID_REGEX = /\d+/;
/*
Manages pricing grid new entries on form :
Listen to clone button.
When triggered, clone the last entry/row wtih updating input values.
*/
function bindNewPricingEntryButton() {
$('.js-btn-add').click(function(e) {
e.preventDefault();
var container = $(this).closest('.pricing_grid_prices');
var row = container.find('.price-entry:last')[0];
var clone = $(row.cloneNode(true));
var stepField = clone.find('.step input[type="number"]');
var currentVal = stepField.val();
// Sets new step field value (previous one + 1)
stepField.val(parseInt(currentVal) + 1);
// Resets new price field
var priceField = clone.find('.price input[type="number"]').val('');
var collIds = _.map(container.find('.price input[type="number"]'), function(node) {
return +node.name.match(ID_REGEX);
});
var collIndex = +_.max(collIds) + 1;
var newName = stepField.attr('name').replace(ID_REGEX, collIndex.toString());
// Adds remove button to cloned entry
clone.find('.btn-delete').remove();
var removeButton = $("\
<button class='btn-floating btn-delete'>\
<i class='material-icons'>remove</i>\
</button>\
");
clone.append(removeButton);
// Updates cloned fields names and label 'for' attributes
stepField.attr('name', newName);
clone.find('.step label').attr('for', newName);
priceField.attr('name', newName);
clone.find('.price label').attr('for', newName);
clone.insertBefore(this);
return priceField.focus();
});
}
/*
Removes a price entry / line when clicking on its delete button.
The first entry cannot be removed (it has no delete button).
*/
function bindRemovePriceEntry() {
$('.pricing_grid_prices').on('click', '.btn-delete', function(e) {
e.preventDefault();
$(this).closest('.price-entry').remove();
});
}
$(document).ready(function() {
bindNewPricingEntryButton();
bindRemovePriceEntry();
});
$(document).on('modal-form-loaded', function() {
bindNewPricingEntryButton();
bindRemovePriceEntry();
});
})();
config/simple_form.fr.yml (Yes, there shouldn't be any HTML tag in YML files…)
fr:
simple_form:
actions:
defaults:
clone_row: Ajouter une valeur
labels:
pricing_grid:
discount_code: Code promotionnel
ends_on: Date de fin de validité
euros: '€ <abbr title="hors taxe">HT</abbr>'
price: Tarif
prices: Grille tarifaire
starts_on: Date de début de validité
step: À partir de
trainees: apprenant(s)