Search code examples
ruby-on-railsrubydatesimple-form

Ruby on Rails: Handling invalid dates with multiparameter dates


I've added a form to my rails app which asks for a date. Rather than use the (IMO) clunky date_select helper, or a date popup solution, I'd like to use seperate input fields for date, month and year (as specified in the GDS service manual). I've written a custom input for simple_form here:

class TextDateInput < SimpleForm::Inputs::Base
  def input(wrapper_options)
    input_html_options[:pattern] = '[0-9]*'
    @value = @builder.object.send(attribute_name)
    @merged_input_options = merge_wrapper_options(input_html_options, wrapper_options)
    "#{date_field} #{month_field} #{year_field}".html_safe
  end

  def date_field
    @builder.label(attribute_name, class: 'grouped-date date') do
      output = template.content_tag(:span, 'Date')
      output += @builder.text_field(attribute_name,
                                    @merged_input_options.merge(
                                      name: "#{@builder.object_name}[#{attribute_name}(3i)]",
                                      maxlength: 2,
                                      value: @value&.day
                                    ))
      output
    end
  end

  def month_field
    @builder.label(attribute_name, class: 'grouped-date month') do
      output = template.content_tag(:span, 'Month')
      output += @builder.text_field(attribute_name,
                                    @merged_input_options.merge(
                                      name: "#{@builder.object_name}[#{attribute_name}(2i)]",
                                      maxlength: 2,
                                      value: @value&.month
                                    ))
      output
    end
  end

  def year_field
    @builder.label(attribute_name, class: 'grouped-date year') do
      output = template.content_tag(:span, 'Year')
      output += @builder.text_field(attribute_name,
                                    @merged_input_options.merge(
                                      name: "#{@builder.object_name}[#{attribute_name}(1i)]",
                                      maxlength: 4,
                                      value: @value&.year
                                    ))
      output
    end
  end
end

And it works perfectly in the frontend, however, if the user enters an invalid date (for example 99/99/9999), Rails raises an ActiveRecord::MultiparameterAssignmentErrors error. Is there a clean way to handle this so rather than raising an error I can apply a validation error to the database object and show an invalid date error to the user?


Solution

  • I decided to have a stab at this myself and added the following to my base model class (I'm using Rails 5):

    class ApplicationRecord < ActiveRecord::Base
      self.abstract_class = true
    
      def update_attributes(attrs = {})
        super parse_dates(attrs)
      end
    
      def parse_dates(attrs)
        # First fetch any nested attributes
        attrs.clone.each do |k, v|
          next unless v.is_a?(Hash)
          # If this is a hash, it's a nested attribute, so check for dates
          attrs = parse_nested_dates(k, attrs)
        end
        # Now marshal the rest of the dates
        marshal_dates(self.class, attrs)
      end
    
      def parse_nested_dates(key, attrs)
        klass = Object.const_get key.split('_attributes').first.classify
        attrs[key] = marshal_dates(klass, attrs[key])
        attrs
      end
    
      def marshal_dates(klass, attrs)
        # Get all the columns in the class that have a date type
        date_columns = klass.columns_hash.select { |_k, value| value.type == :date }.keys
        date_columns.each { |c| attrs = parse_date(attrs, c) }
        attrs
      end
    
      def parse_date(attrs, property)
        # Gather up all the date parts
        keys = attrs.keys.select { |k| k =~ /#{property}/ }.sort
        return attrs if keys.empty?
        values = []
        keys.each do |k|
          values << attrs[k]
          attrs.delete(k)
        end
        # Set the date as a standard ISO8601 date
        attrs[property] = values.join('-')
        attrs
      end
    end
    

    This seems to work perfectly for both standard attributes and nested attributes, and means it automatically works for all date columns without me having to do anything.