How to handle non-standard model user input for complex types, i. e. duration, in forms?

Hello,

Just started using Rails and was stumped by the following problem. I want to create an application that tracks a user’s runs/rides/activities where an activity is defined by a start date (datetime), duration (integer, number of seconds) and distance (integer, number of m traveled).

Creating a form that lets the user input these values is simple enough using the form_with helper. But it’s far from a good user experience. What I would like to achieve is to have three separate text fields for inputting the duration (hours/minutes/seconds). There needs to be a translation from the model’s duration in seconds to the views three fields and vice versa when submitting the form. But where exactly do I put this translation? What is the Rails way of doing non-standard model form input?

Do I just add three form fields not associated with the model in the form_with helper in my view and handle the translation in the controller? Is there a better way? Would be grateful if anyone had any examples of this kind of form handling.

Thanks.

There is a built-in way that Rails can do this called Multiparameter arguments. Here’s a blog post series that I think is exactly what you’re looking for: Multiparameter attributes with Ruby on Rails - CookiesHQ

…but as you’ll see from the blog posts it’s a bit complicated and unpublic.

Instead, I would recommend doing it inside of the model with accessors and callbacks. It’s more verbose, but given that this is the feature of your application, I believe you’d want more control over the lifecycle, surfacing errors, etc. This is a very simplistic version, but hopefully gives you an idea of how to go about it:

class Run < ApplicationRecord
  # `duration` is a column in the database
  attr_accessor :duration_hour, :duration_minute, :duration_second
  before :validation, :set_duration

  def duration_hour
    super || extract_hour_from(duration)
  end

  def duration_minute
    super || extract_minute_from(duration)
  end

  def duration_second
    super || durationextract_second_from(duration)
  end

  private

  def set_duration
    self.duration = construct_duration_from(hour, minute, second)
    self.duration_hour = nil
    self.duration_minute = nil
    self.duration_second = nil
  rescue
    errors.add(:duration_second, "Not a valid value")
  end
end

Things can get complicated once you depart from 1-1 model attribute to form field mappings. For your particular case, I’d encapsulate the duration aggregation logic in a value object.

class Run < ApplicationRecord
  before_validation { self.duration = run_duration.to_i }

  def run_duration
    @run_duration ||= RunDuration.new(duration)
  end
end

class RunDuration  
  def initialize(seconds = 0, minutes = 0, hours = 0)
    self.seconds, self.minutes, self.hours = seconds, minutes, hours
  end

  def seconds=(val)
    @seconds = ActiveSupport::Duration.seconds(val)
  end

  def minutes=(val)
    @minutes = ActiveSupport::Duration.minutes(val)
  end
  
  def hours=(val)
    @hours = ActiveSupport::Duration.hours(val)
  end

  def to_i
    total.to_i
  end
  
  def seconds
    ActiveSupport::Duration.build(to_i).parts[:seconds]
  end
  
  def minutes
    ActiveSupport::Duration.build(to_i).parts[:minutes]
  end
  
  def hours
    ActiveSupport::Duration.build(to_i).parts[:hours]
  end
  
  private

  def total
    @seconds + @minutes + @hours
  end
end

This allows us to clearly separate the logic of converting hour / minute / second parts to and from seconds, delegating the heavy lifting to ActiveSupport::Duration as a bonus. If this is only used by one view, I’d just use assign separate form fields and associate them in the controller.

However, if it’s used in multiple areas, i’d consider exposing it through the model object, eg:

<%= f.number_field :run_duration_seconds %>

Run.update(run_duration_seconds: 100)

My model needs to implement #run_duration_seconds and #run_duration_seconds=

class Run
  def run_duration_seconds
    run_duration.seconds
  end

  def run_duration_seconds=(val)
    run_duration.seconds = val
  end
end

I can also do this via delegation:

class Run < ApplicationRecord
  delegate :seconds, :seconds=, :minutes, :minutes=, :hours, :hours=, to: :run_duration, prefix: true
end

There’s no definitive answer to your problem as it’s outside the scope of vanilla Rails. At the end of the day, I’d say go with the minimum structure you need for your use case, and reorganise when it becomes necessary.

Thank you for the detailed answers. Using attributes and encapsulating the conversion logic in a class seems like the way to go here. Setting the duration in the before_validation callback was the insight I needed.

ActiveSupport::Duration seems kind of limited for this use case? If I input say 25 hours and get the :hours part it returns 1? Since it’s 1 day and 1 hours. Not that any activity is likely to be longer than 24 hours but still.

Yeah, you’ll need to handle this edge case if you cap display at hours. Rails 6.1 has you covered with ActiveSupport::Duration#in_hours

  def hours
    ActiveSupport::Duration.build(to_i).in_hours.round
  end