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?
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.