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.
A custom input type allows you to extend SimpleForm’s default input types.
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.

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.

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
endprivate def usp_list
initialize_list!
entries = object.public_send(attribute_name)
entries.push(blank_entry) if entries.blank?
entries
endprivate def initialize_list!
return if object.public_send(attribute_name).is_a?(Array) object.public_send("#{attribute_name}=".to_sym, [])
endprivate 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
endprivate def build_entry_label(entry, index)
template.content_tag(:div, class: 'col-12 label') do
@builder.label(entry_label_for(entry, index))
end
endprivate def build_entry_text_field(entry, index)
template.content_tag(:div, class: 'col-12 text') do
build_field_for(entry, :text, index)
end
endprivate def build_entry_note_field(entry, index)
template.content_tag(:div, class: 'col-12 note') do
build_field_for(entry, :note, index)
end
endprivate 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)
endprivate 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'
)
endprivate def field_name_for(name)
"#{object_name}[#{attribute_name}][][#{name}]"
endprivate def field_id_for(name, index)
"#{object_name}_#{attribute_name}_#{name}_#{index}"
endprivate 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.

- 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