Custom input fields with SimpleForm

tl;dr; This article shows you how to build a quite more complex custom input type with the well known form-abstraction SimpleForm and related Bootstrap-markup. #RubyOnRails #SimpleForm

[Level: Mid | Created: 2020–09–26 | Updated: 2020–10–19]

Introduction

Recently I had the requirement to add several USP-lists to a product-model. A single USP consists of a text and maybe a note (shown with an asterisk-sign or tooltip on products detail-page). This must also be maintained by our backend. Since I don't want to build a creepy hack with a ton of nasty JS: I decided to take a look at custom input types provided by SimpleForm.

You can find simple examples for some kind of array-like inputs here [1] and a small tutorial here [2]. For our purposes, these examples are not sufficient, cause we need to incorporate more than one input-field.

Note: Only relevant parts of code are illustrated. Sometimes you need to abstract provided code and fit it to your needs, cause it’s taken from my concrete implementation and not customized for this article (e.g. js-code).

Tasks

  • Extend underlying model for storing USP-lists
  • Write the custom input class for handling composed USP-entries
  • Integrate it in form-view and properly extend param-handling
  • Implement some js-code for adding and removing USP-entries

Model

A USP-List in our model is an array of hashes with the keys text and note. The concerned model has for some reason a JSONB datastructure named :properties with a store-accessor. But you can also use an array-type in your model.

First of all we need to extend the underlying model. For proper initialization, this code lives in the model.

#  properties :jsonb, not null, default: {}store_accessor :properties, :description, :usp_listafter_initialize :define_properties, if: :new_record?def define_properties
return if properties.any?
self.properties = {
description: nil,
usp_list: [],
# ...
}
end

Adding a new USP can optionally also be done with a simple method.

def add_usp(text, note)
usp_list << { text: text, note: note }
end

Now you got a synoptic view on the purposed datastructure.

Form view and param-handling

Before we go ahead with building the custom input, I’ll show you how it is intended to be used in our form. Please notice the option ‘as:’ which specifies the type of input and must be used for the specified attributes.

Note: Cause we use store-accessor in our model, you can directly access a JSONB-attribute (no need to deal with the properties-hash).

= f.input :usp_list, as: :usp_list
# We have some more usp-lists in our model, e.g.:
= f.input :features_usp_list, as: :usp_list

You also need to handle the permitting of the UPS-Lists in your controller. The code example shows you how to do this properly for an array of hashes.

private def product_params
params.require(:product).permit(
:description,
# ... some other attributes
usp_list: %i[text note],
features_usp_list: %i[text note]
)
end

Hint: Complex params like our ‘usp_list’ must be the last arguments in the permit-method. Otherwise, they will be silently ignored … whyever [3].

Custom input type

Before we start coding, let’s take a look at the desired outcome of our custom input type. This is an example for a new blank USP-Entry, which our custom input class will build upfront, if no entry exists yet.

Image for post
Image for post
A blank USP-Entry

Note: Plain text in these figures is in german, code shows english terms.

Since we work with Bootstrap in our backend, we have to build a more sophisticated markup for our custom input type. SimpleForm generates it’s own typical markup, which will be extended by our custom input class. Here is the full markup for the above shown illustration.

Image for post
Image for post

Note: The outermost div-wrapper and the following label-tag is automatically generated by SimpleForm. For completeness, this is the markup in our form, which adds some additional classes for the wrapper and the label.

= f.input :usp_list, as: :usp_list, label_html: { class: 'usp-list' }, wrapper_html: { class: 'usp-list' }

Fulfilment

Inside automatically generated markup by SimpleForm we need to:

  • Add another div-wrapper for the whole USP-List
  • Wrap each USP-Entry in a bootstrap-row
  • Wrap inside this: bootstrap-columns for label, text-input and note-input
  • And of course inside these bootstrap-columns our desired form-elements

First scaffold for our custom input

Now after he have clarified what we gonna build, I’ll show you step by step the final solution. Starting with a basic scaffold, which follows the conventions of SimpleForm. I will not explain every method in detail, but you will get an idea of how to build this by yourself.

Note: Already shown and finally implemented methods will be removed from the snippets for better readability. New methods maybe shown incomplete. Snippets will grow step by step. The final code is provided by a Gist [4].

Our new input type will be named ‘UspListInput’. SimpleForm expects it to be in this path: app/inputs/usp_list_input.rb

Methods :input and :input_type are expected presets by SimpleForm.

class UspListInput < SimpleForm::Inputs::StringInput
def input(_wrapper_options)
input_html_options[:type] ||= input_type
end
def input_type
:string
end
end

Building outer wrapper for USP-Lists

Now let’s build our custom div-wrapper for the whole USP-List.

class UspListInput < SimpleForm::Inputs::StringInput
def input(_wrapper_options)
template.content_tag(
:div, id: input_id,
class: custom_wrapper_style, data: { prefix: input_id }
)
do
template.concat(build_markup)
end
end
# Will be extended in a further step
private def build_markup
markup = []
markup.join.html_safe
end
private def input_id
"#{object_name}_#{attribute_name}"
end
private def custom_wrapper_style
self.class.name.tableize.parameterize.dasherize
end
end
  • For simplification, we ignore argument :_wrapper_options in method :input
  • Method :template is provided by SimpleForm
  • :object_name and :attribute_name is also provided by SimpleForm
  • Output of method input_id: e.g. “product_usp_list”

Prepare building markup for USP-Entries

Now we will fetch the desired USP-List (derived from :attribute_name) from the underlying product-model and extend building our required markup. If the desired USP-List is empty, we will add a blank entry to our list for showing it up in our form-view (look at figure above showing a blank USP-Entry).

private def build_markup
markup = []
usp_list.map.with_index(1) do |entry, index|
markup << build_entry(entry, index)
end
markup.join.html_safe
end
private def usp_list
initialize_list!
entries = object.public_send(attribute_name)
entries.push(blank_entry) if entries.blank?
entries
end
private def initialize_list!
return if object.public_send(attribute_name).is_a?(Array)
object.public_send("#{attribute_name}=".to_sym, [])
end
private def blank_entry
{ text: nil, note: nil }
end
# Will be extended in a further step
private def build_entry(entry, index); end
  • Method :build_markup has been extended and iterates now over existing entries from underlying model using generic method :usp_list (via attribute_name) and builds the desired markup for each USP-Entry
  • Method :usp_list loads present entries from model via attribute object using attribute_name (provided by SimpleForm) or adds a blank entry
  • Method :initialize_list! takes special care of proper initialization (maybe sth. in the underlying model could be fucked up)
  • Method :build_entry will be building desired markup (shown in next step)

Extend building markup for USP-Entries

In this step we will build the bootstrap-row for each USP-Entry and wrap each itself inside it: opining the label, text-input and note-input for each of them by extending the method :build_entry.

private def build_entry(entry, index)
template.content_tag(:div, class: 'row usp-entry') do
result = []
result << build_entry_label(entry, index)
result << build_entry_text_field(entry, index)
result << build_entry_note_field(entry, index)
result.join.html_safe
end
end
private def build_entry_label(entry, index)
template.content_tag(:div, class: 'col-12 label') do
@builder.label(entry_label_for(entry, index))
end
end
private def build_entry_text_field(entry, index)
template.content_tag(:div, class: 'col-12 text') do
build_field_for(entry, :text, index)
end
end
private def build_entry_note_field(entry, index)
template.content_tag(:div, class: 'col-12 note') do
build_field_for(entry, :note, index)
end
end
private def entry_label_for(entry, index)
return 'New entry' if entry[:text].blank? && entry[:note].blank?
"Entry #{index}"
end
# Will be extended in a further step
private def build_field_for(entry, name, index); end
  • Method :build_entry builds the bootstrap-columns for each required form-element and wraps them inside a bootstrap-row for each entry
  • Object :builder is provided by SimpleForm

Finalize building markup for USP-Entries

Now we got the outer div-wrapper, a row-wrapper for each USP-Entry and the column-wrappers for our required form-elements. The label is already done inside our code, but the input-fields for :text and :note are still missing.

Let’s finalize it!

private def build_field_for(entry, name, index)
options = input_html_options_for(entry, name, index)
@builder.text_field(nil, options)
end
private def input_html_options_for(entry, name, index)
input_html_options.merge(
value: entry[name],
name: field_name_for(name),
id: field_id_for(name, index),
placeholder: placeholder_for(name),
class: 'form-control'
)
end
private def field_name_for(name)
"#{object_name}[#{attribute_name}][][#{name}]"
end
private def field_id_for(name, index)
"#{object_name}_#{attribute_name}_#{name}_#{index}"
end
private def placeholder_for(name)
return 'USP' if name.eql?(:text)
return 'Note' if name.eql?(:note)
nil
end
  • Method :build_field_for is quite a bit abstracted, but it just handles building the required input-fields (by using @builder.text_field)
  • It also uses some helper-methods for building up distinct ID- and name-attributes for our input-fields inside method :input_html_options_for
  • Syntax of method field_name_for is important for having an array of hashes! Checkout the difference acc. to. already appropriated tutorial [2]

Note: Dispatching the outermost index-variable through the whole method-cascade inside our custom-input-class is required for distinguishing the form-inputs acc. to unique HTML-IDs.

Wrap up the present code

What we achieved for now in our custom input type with bootstrap-compliant-markup is:

  • Accessing and initializing an array-based attribute from our model
  • Building up a spanning wrapper-markup for our whole USP-List
  • Iterating through each entry of our USP-List with having a row
  • Having a solid column-based-markup for each USP-Entry
  • Showing a label and the required input-fields for each entry
  • In such an awesome abstraction, that we can resuse it many times

For our custom input class, you will find the comprehensive code here [4].

But: are we done here? Nope!

UI-Handling

We still have a remaining task. For now we can render our present UPS-Lists with a custom input-type inside SimpleForm and editing them according to their already present values. What’s missing is adding and removing USPs. Both of them requires injecting some more markup on our generated USP-Lists powered by our custom input type. For this task, we will use some simple js-code based on jQuery (just for demonstration). Maybe you’ll like doing it in plain vanilla or whatever. Beforehand let’s take a look on what it should look like.

Image for post
Image for post
  • Now we must append a plus-button for adding a new USP-Entry
  • And a simple cross-button for removing each entry too
  • If the USP-List is empty, our implementation already prebuilds a blank one

OK, let’s dive into some js-code for this.

Note: These snippets are not for copy and paste. Especially we use markup powered by FontAwesome, have a TurboLinks-Integration and custom js-namespaces. You will need to adjust this code to your custom requirements. But that’s your job 😏

First of all, we need to inject the action-icons for adding and removing:

'use strict';$(document).on('turbolinks:load', () => {
app.backend.components.usp_lists.setup();
});
app.backend.components.usp_lists = {
setup: function() {
this.injectButtons();
},
injectButtons: function() {
var add_markup = '<a title="USP hinzufügen" class="btn btn-outline-secondary action-icon add-usp" href="#add-usp"><i class="fas fa-plus"></i></a>';
var remove_markup = '<a title="USP entfernen" class="remove-usp" href="#remove-usp"><i class="fas fa-times"></i></a>'; $(add_markup).insertAfter("div.usp-list > label");
$(remove_markup).insertAfter("div.usp-list div.label > label");
},
};

Looks a bit ugly, but this tutorial is not about writing cool js-code … sincerely it works. However: nothing is in global namespace! Next up we need the click-handlers, here is the final code as yet:

'use strict';$(document).on('turbolinks:load', () => {
app.backend.components.usp_lists.setup();
});
app.backend.components.usp_lists = {
setup: function() {
this.injectButtons();
this.applyAddButtonHandler();
this.applyRemoveButtonHandler();
},
injectButtons: function() {
var add_markup = '<a title="USP hinzufügen" class="btn btn-outline-secondary action-icon add-usp" href="#add-usp"><i class="fas fa-plus"></i></a>';
var remove_markup = '<a title="USP entfernen" class="remove-usp" href="#remove-usp"><i class="fas fa-times"></i></a>';
$(add_markup).insertAfter("div.usp-list > label");
$(remove_markup).insertAfter("div.usp-list div.label > label");
},
applyAddButtonHandler: function() {
$('div.usp-list').on('click', 'a.add-usp', function(e) {
e.preventDefault();
var $container = $(this).next();
var $markup = $container.find('.usp-entry').first().clone();
var prefix = $container.data('prefix');
$container.append($markup); var $newEntry = $container.find('.usp-entry').last();
var items = $container.find('.usp-entry').length
$newEntry.find('label').html('Neuer Eintrag'); $newEntry.find('div.text input').val('');
$newEntry.find('div.note input').val('');
$newEntry.find('div.text input').attr('id', prefix + '_text_' + items);
$newEntry.find('div.note input').attr('id', prefix + '_note_' + items);
});
},
applyRemoveButtonHandler: function() {
$('div.usp-list').on('click', 'a.remove-usp', function(e) {
e.preventDefault();
var $container = $(this).parents('.usp-list-inputs'); if($container.find('.usp-entry').length == 1) {
$container.find('.usp-entry label').html('Neuer Eintrag');
$container.find('.usp-entry input').val('');
}
else {
$(this).parents('.usp-entry').remove();
}
});
},
};
  • Building a new USP-Entry works by cloning an existing entry, resetting this to an initial state and appending it to the end of the current entries
  • As you may have noticed, we access a prefix-data-attribute. This is provided by our custom input implementation. We use it for building new entries in order to have an unique id-attribute.
  • Removing a USP-Entry has an edge-case. If there is more than one entry, just remove the concerned. If not, we keep at least one entry and just reset it for having the markup present in order to clone a new one, if we press the add-button afterwards
  • That’s also the reason, why we prebuild a blank entry upfront in our custom input. Otherwise we would be compelled to recreate twice the complex markup for our UPS-entries in js-code
  • This way, we have avoided this problem and we do not neither have to double our markup in js-code, nor to keep it in sync!

Note: This js-code depends highly on the markup-structure of our custom input implementation. Swapping markup will of course break js-code.

One more thing

If the USP-List is currently empty, our custom input type enforces building and thus showing a blank entry. This is desired in our implemention for already explained reasons (no duplication of markup-structure in js). But if you add many new entries via UI, you will fuck up your datastructure with empty entries. Though that’s not a huge problem. We just need to extend our model for taking care about that. Here we go:

before_save :reject_empty_usp_listsdef reject_empty_usp_lists
%i[usp_list features_usp_list].each do |list_name|
next if public_send(list_name).nil?
public_send(list_name).reject! { |entry| entry[:text].blank? && entry[:note].blank? }
end
end

We just filter out all blank entries before processed by any save-operation for each of our USP-Lists in our concerned model.

Note: We have indeed in our current implementation four different USP-Lists, but that’s a detail I had kept secretive in this tutorial. But I have referred at least to two of them in most of all code-snippets.

Summary

Yeah: this was a very debaucherous article with many LOC and explanations. It was a bit exhausting to provide code-examples on this platform due to painfull formatting-issues. All in all, I hope you could follow it and you got an idea of what was going on. Rather I’ll be happy, if you also understood very well: how to implement your own custom inputs using SimpleForm.

Note: Of course this code could be optimized furthermore, e.g. I18N, …, but this is just for demonstration of the current state 🤓

Futurely, I think I’ll add some more details about the internals of SimpleForm in order to provide more background-knowledge for implementing custom inputs. Stay tuned: this article may receive an update … or I’ll write another one with more details on the basics of writing custom inputs. Cheers🍻

PS: Leave a comment if you like!

#CommentsAreWelcome

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store