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