In part 1, we looked at how Rails handles multiparameter attributes – like dates – in forms and models. This time, we’ll be looking at how we can use this ourselves.
SimpleForm input
For this example, we’ll be constructing an input for SimpleForm (v3.2.1 at time of writing), but the code could be adapted for Formtastic or even plain Rails.
Imagine, if you will, that we’re building an app for ordering pizzas, and we want each pizzeria we’ve signed up to be able to give us the time it takes for them to make a pizza, so that we can show users an estimated delivery time when they order through our app. Since time durations are made up of multiple parts (hours, minutes, seconds), we’d like a human-friendly multiparameter input (with a limited number of times – we only need to be accurate to the nearest 15 minutes), but we want to store the prep time as a simple integer (the number of seconds) in the database for ease of calculation.
The basics
The SimpleForm docs talks you through the basics of creating your input type, so I’d recommending having a look at that before you continue.
Since our input isn’t really similar to any of the existing SimpleForm inputs (and date/time inputs come with a lot of stuff we don’t need), our new input type is subclassed from the basic SimpleForm::Inputs::Base
. We start with a bit of boilerplate in our #input
method, then construct two <select>
elements – one for hours, one for minutes – with the default value of 0
for both.
# app/inputs/duration_input.rb
class DurationInput < SimpleForm::Inputs::Base
OPTIONS = [
0..4, # hours
[0, 15, 30, 45] # minutes
]
def input(wrapper_options = nil)
# This is present on all SimpleForm inputs
merged_input_options = merge_wrapper_options(input_html_options, wrapper_options)
2.times.map do |index|
@builder.select("#{attribute_name}(#{index + 1}i)", OPTIONS[index], { selected: 0 }, merged_input_options)
end.join(" ")
end
end
We can use our new input like so (you’ll probably have to restart your server in order for SimpleForm to recognise our new input):
= simple_form_for @pizzeria do |f|
= f.input :prep_time, as: :duration
Fixes
Let’s have a look at what we get:
<div class="pizzeria_prep_time">
<label class="duration optional" for="pizzeria_prep_time">Prep time</label>
<select class="duration optional" name="pizzeria[prep_time(1i)]" id="pizzeria_prep_time(1i)">
<option selected="selected" value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
<select class="duration optional" name="pizzeria[prep_time(2i)]" id="pizzeria_prep_time(2i)">
<option selected="selected" value="0">0</option>
<option value="15">15</option>
<option value="30">30</option>
<option value="45">45</option>
</select>
</div>
Hmm…looks good, but there’s a few things not quite right. Let’s start by removing the brackets from the id
by writing a new method:
def generate_id(index)
# object_name => underscored version of the class name
"#{object_name}_#{attribute_name}_#{index + 1}i"
end
and incorporate it into the input
method:
def input(wrapper_options = nil)
merged_input_options = merge_wrapper_options(input_html_options, wrapper_options)
2.times.map do |index|
# ADDED
options = merged_input_options.merge id: generate_id(index)
# MODIFIED
@builder.select("#{attribute_name}(#{index}i)", OPTIONS[index], { selected: 0 }, options)
end.join(" ")
end
Secondly, the for
attribute for the <label>
isn’t pointing to an actual id
of one of the <select>
elements, meaning that clicking the label doesn’t focus the corresponding form element as expected. Handily, SimpleForm has a method for just this case, where we can specify which id
we want the label to point to:
# Highlight first <select> when <label> clicked
def label_target
"#{attribute_name}_1i"
end
Note that, unlike the generate_id
method, SimpleForm will add the object_name
prefix for us.
Now we can take another look at the generated HTML:
<div class="pizzeria_prep_time">
<label class="duration optional" for="pizzeria_prep_time_1i">Prep time</label>
<select class="duration optional" id="pizzeria_prep_time_1i" name="pizzeria[prep_time(1i)]">
<option selected="selected" value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
<select class="duration optional" id="pizzeria_prep_time_2i" name="pizzeria[prep_time(2i)]">
<option selected="selected" value="0">0</option>
<option value="15">15</option>
<option value="30">30</option>
<option value="45">45</option>
</select>
</div>
Issues fixed!
Current value
Of course, our new input is only useful at the moment for creating new records – if we want to edit existing records we need our input to use the existing value, if present.
Although SimpleForm doesn’t provide a method to get the current value, we can access the underlying record with object
and thus get the current value by sending it attribute_name
. We can can then use the divmod
method to get an array of [hours, minutes]
:
def input(wrapper_options = nil)
merged_input_options = merge_wrapper_options(input_html_options, wrapper_options)
# ADDED
# Get the current value from the record and `divmod` by 60
# giving us an array of [hours, minutes]
current_duration = object.send(attribute_name).divmod(60)
2.times.map do |index|
# MODIFIED
@builder.select("#{attribute_name}(#{index}i)", OPTIONS[index], { selected: current_duration[index] }, merged_input_options)
end.join(" ")
end
The final product
Here’s what the completed input looks like (I’ve taken the liberty of extracting current_duration
into a separate method):
class DurationInput < SimpleForm::Inputs::Base
OPTIONS = [
0..4, # hours
[0, 15, 30, 45] # minutes
]
def input(wrapper_options = nil)
merged_input_options = merge_wrapper_options(input_html_options, wrapper_options)
2.times.map do |index|
options = merged_input_options.merge id: generate_id(index)
@builder.select("#{attribute_name}(#{index + 1}i)", OPTIONS[index], { selected: current_duration[index] }, options)
end.join(" ")
end
# Highlight first <select> when label clicked
def label_target
"#{attribute_name}_1i"
end
private
def current_duration
object.send(attribute_name).divmod(60)
end
def generate_id(index)
"#{object_name}_#{attribute_name}_#{index + 1}i"
end
end
Multiparameter writers
Now that we have a form input to submit multiparameter attributes, we need to make sure that our model can understand them.
As we saw in part 1, Rails will automagically transform any multiparameter attributes into a hash keyed by number (in brackets on the attribute name), optionally cast into the desired type (in this case, i
in the attribute name gives us integers).
So, our form above will submit something like this to the controller:
{
"pizzeria" => {
"prep_time(1i)" => "3",
"prep_time(2i)" => "30"
}
}
which will be transformed into the following when it reaches the model:
{
"pizzeria" => {
"prep_time(1i)" => { 1 => 3, 2 => 30 }
}
}
Rails allows us to redefine the attribute writer (which is also used for mass assignment in new
, build
, assign_attributes
etc.) so that we can convert the hash value into a single integer. We can then use the underlying []=
method to set the converted value directly:
# app/models/pizzeria.rb
def prep_time=(time)
if time.is_a?(Hash)
hours, minutes = time.values_at(1, 2)
time = (hours * 60) + minutes
end
self[:prep_time] = time
end
Conclusion
We’ve now seen how Rails constructs and parses multi-select date inputs, and used that knowledge to build our own form input. I hope this has given you a better understanding of how Rails handles dates, and given you some ideas on how to handle your own multi-part data types.
Image by CéLOGIK from Flickr in the public domain