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?