Skip to content

Instantly share code, notes, and snippets.

@mbrehin
Last active January 3, 2021 18:22
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mbrehin/b4bfd410e1155ecc1fab to your computer and use it in GitHub Desktop.
Save mbrehin/b4bfd410e1155ecc1fab to your computer and use it in GitHub Desktop.
Rails SimpleForm custom input for pricing grids with materialize.css

Create a multi-entries custom input with SimpleForm and Materialize.css

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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment