A More User-friendly date_select() Alternative in Rails

UPDATE: I now regard this article is completely defunct and I rescind and disavow association with any and all comments I may have made in it. For one thing, use attributes_before_type_cast instead of @attributes. Secondly, my experience in time has been that this kind of date input simply confuses people.

Using drop-downs to select a date is lame and annoying. However, the built-in date_select method for dealing with dates in Rails (e.g., what scaffolding throws at you,) is a just a line of drop-downs for day/month/year. So I wanted to come up with an alternative.

It’s much more user-friendly to supply a text field and let the user enter the text of the date however they want. We can then ask Ruby to interpret what the user said and turn it into a standard date format.

In fact, Rails will do this fairly automatically for us. For example, on the the new Lovetastic site we’re building, we ask users for their birth dates so we can calculate users’ ages over time.

There are a couple of snags, which I’ll discuss below. But Rails does a lot of the heavy lifting for us.

In the database, we have

...
create_table :users do |table|
  ...
  table.column :birthdate, :date, :null => false
  ...
end

So, the point here is that Rails is going to know, through its magical capacity for introspection on our database column types, that User.birthdate refers to a date.

In our form, we do:

text_field 'user', 'birthdate', :size => '25'

In our controller, simply:

@user = User.new(params[:user])

Now, self.birthdate will be available in the User model as a Date object, so we can do validations with it using all the normal methods available through Date — for example, checking whether the user is older than a certain age.

now = Time.now
dob = self.birthdate

#     how many years?       has their birthday occured this year yet?      subtract 1 if so, 0 if not
age = now.year - dob.year - (dob.to_time.change(:year => now.year) > now ? 1 : 0)

if age < 18
  errors.add_to_base "You must be eighteen to register"
end  

However, there are a couple gotchas.

Firstly, if a user enters “4/28/81”, the Ruby Date object will interpret this as April 28 of the year 0081. This obviously is unlikely, so you might want to do something like:

if self.birthdate.year < 1900
  self.birthdate += 1900
end

We’ve actually written a more robust way of handling this problem, which will work for dates well into the future, but you get the picture. :)

Secondly, if a Date object gets passed a string it can’t understand, that object will be set to nil. This means that Rails will throw a confusing error for an invalid date. It will say “Birthdate can’t be blank” if you entered something that the Date object can’t parse. This isn’t very user friendly, so we need a way to distinguish between a blank submitted date field and one that is just invalid.

At first, I was doing this in a dumb way. I was setting up @submitted_birthdate_as_string = params[:user][:birthdate] in the controller, setting attr_accessor :submitted_birthdate_as_string in the model, and then checking whether that was blank and throwing the appropriate error accordingly from the model.

The reason I did this was because Rails was doing a funny thing.

In the user model self.birthdate returns nil if the user either submitted a blank birth date or an invalid date. This is because self.birthdate returns a Date object (to be picky,a to_s output of our Date object), not the string that was entered into the form. Therefore, I couldn’t figure out how to tell whether the submitted string was blank or just invalid as a Date. I was passing it as a variable from the controller into the model to check if it was blank, which is a fairly messy solution.

However, with a little poking around with the breakpointer, I was able to get to the bottom of the what was going on (and come up with a better solution).

If you set a breakpoint in the model in the validate method, you’ll find that calling self.attributes returns a hash. One of the keys is birthdate, which is also nil. However, if we look at the @attributes instance variable, we also get a hash. Interesting thing is that this has a key birthdate, but it has a value of the originally-entered string rather than nil. The reason these are different (presumably) is because Rails is working its magic on the attributes of the User object since it knows the database is expecting a SQL date field and setting up its methods to return (and expect) a Date object. Isn’t it nice, though, that we can still get at the original parameters via the @attributes hash? Damn it, Rails is sexy.

So, if I entered “not a date” in the birthdate field in my form, then self.birthdate in the User model would return nil but @attributes['birthdate'] would return the orginally-entered string, "not a date".

Knowing this, we can now write the following in our validate method:

if self.birthdate.nil? && @attributes['birthdate'].empty?
  errors.add "birthdate", "is empty"
elsif self.birthdate.nil?
  errors.add "birthdate", "is invalid"
end

Et voila. Our user now gets an “invalid” error when a submitted date is invalid and a different “empty” error when no birth date has been submitted. Isn’t that better for everybody involved?