How many times have you cursed at a date form field because it rejected 03/01/22
and wanted 03/01/2023
? Number inputs are the same—what if instead of copy & pasting from the calculator app you could enter 79 / 2
in a field and have it evaluate to 39.5
? Inputomatic makes this possible in your Rails app, which means less friction in your UIs for the people who use your app.
It’s 2023 and here’s the best answer web browsers have given us to enter dates & times on popular browsers like Safari.
And Chrome.
What’s maddening about them is they force people to conform to the computer. It should be the other way around—the computer should be more forgiving to the messiness of the human condition and conform to our imprecise inputs.
Let’s see if we can do better in one of the worlds most exciting and reviled class of applications: the expense reports!
An Expense Reporting App
By far one of the most frustrating experiences of our lives is entering expense report data. Setting the date involves clicking on a bunch of different arrows, buttons, etc. to set the date on some poorly designed picker. Then, there’s always some policy that says “expense half of your cellphone bill”, we have to open a calculator to do simple calculations like 124.99 / 2
.
What if we could build forms that made sense of inputs like Last week
for dates and 124.99 / 2
for numbers?
That’s a little better! When we save it we get what we’d expect: a date from last week and the calculated cost.
Let’s try creating an expense from 900 days ago, which is outside of the “90 days ago” policy in our expense account:
Rails preserves the input value until the validation is cleared, then it persists the actual date or number to the database.
Try the demo app and enter a new expense. I promise it will be one of the most luxurious expenses you’ve ever entered. You can also clone the demo app and run it in your own machine or deploy it to Fly.io.
How does that work exactly?
The ActiveModel attribute
class method
When we look at the expense.rb
Active Record model code, we see the class method attribute
and pass it an Inputomatic
class the casts the values to and from the model, form, and the database.
class Expense < ApplicationRecord
attribute :purchased_at, Inputomatic::DateTime.new
attribute :amount, Inputomatic::Number.new
validates :purchased_at,
inclusion: {
in: Range.new(90.days.ago, 90.days.from_now),
message: -> (object, data) { "#{data[:value].to_date} is not between #{90.days.ago.to_date} and #{90.days.from_now.to_date}" }
}
end
Inputomatic is a gem I created that defines a few casting behaviors between the value in the Active Record model and the database. Turns out you can do some pretty nifty things with ActiveRecord::Type
classes that make forms more of a joy to use.
The ActiveRecord::Type::Value#cast_value
method
Let’s have a look at the Inputomatic::Number
class, which is a subclass of activerecord::Type::Value
:
class Number < ActiveRecord::Type::Value
def cast_value(value)
value.is_a?(String) ? ArithmeticInterpreter.new(value).parse : value
end
end
The cast_value
method is where all the magic happens. If the value is a String
, it’s coming from a form as an arithmetic expression. Inputomatic’s arithmetic interpreter (which was written by my pair bear, GPT-4) parses the string 124.99/2
into an expression that it can safely evaluate (never ever run form input through Kernel#eval
, which Stack Overflow would tell you to do) into a number.
Once we have a number, case closed! We have a float, integer, or decimal that can be persisted to the database.
If the value is not a String
, then we just return the value assuming it will be handled by any upstream handlers. In this case we’ll assume its an Integer
or Float
.
“Pickers” and humanized form inputs can co-exist
If you still love date pickers, color pickers, etc. you can keep them! Humanized form inputs would read these values and do less interpretation to them before casting them into values the database can understand.
For people who want to skip the pickers, they’d get a better experience. For people who love clicking around on pickers, that would all still work too.
Limitations
There’s always room for mistakes when a machine interprets something a human enters. It’s important to understand how important it is to capture precise information. For example, when we enter last week
does that mean 7 days ago
or The last day of last week
?
For an expense report application, there’s probably a policy that checks the dates to make sure it’s within the past 90 days. If the arithmetic of a float is off by $0.000000000001, its not the end of the world.
There are localization issues to think through too—for example in some countries one thousand is written as 1,000
and in others it’s written as 1.000
.
Rails validations
Additionally, some Rails validations, like validates :amount, numericality: { greater_than: 0 }
don’t work out of the box because they do regular expression tests against strings to check if they’re floats or integers.
# Rails validations check the string with regular expressions before the value is cast by `Inputomatic::Number.new`, which
# makes these validations not work properly.
validates :amount,
numericality: { greater_than_or_equal_to: 0 }
If you tried to add a numericality validation to the demo, it would complain that 124.99/2
is not a number. This validation could probably be patched to perform validations after the value is cast.
Chronice Time parsing gem
Inputomatic uses the Chronic gem to parse dates, which has a lot of issues and PRs that are worth understanding to get a feel for how well date parsing works.
If you enter the value March 30
into a form on April 1, 2023, you’d be surprised to get back March 30, 2024
. That’s because Chronic is currently only setup to handle partial dates in the future or the past—there’s no way to tell Chronic to “assume the current year if none is given”.
You could set Chronic’s context for the past, but then you’d be surprised to get the date December 1, 2022
when you enter the value December 1
.
More ways to make your application forms more humane
This technique could be used in a lot of places including:
- Telephone numbers - Cast numbers like
800flowers
into numbers or infer the country code with a gem like Phoney. - Countries or states - If somebody enters
US
,USA
,United States of America
, etc. into a form field for country, the country code could be properly resolved when the value is cast. Same for states! The Countries gem could be a good start. - Colors - Convert “black” to
#000
, “white” to#fff
, etc. - URLs - If you want to always ensure URLs have
https
, you could cast the String value into aURL
object, setURI#scheme = "https"
, then save out the string into the database so it’s always https. Maybe you just want to always strip off the trailing slash. - Capitalization - If you have fields that must be stored in lowercase on the database, you could do it for the user automatically with a LowerCase type that always lower cases values.
- Location - I don’t recommend running strings through expensive API calls when casting a value, but if you can make the call “fast and cheap” enough, there’s nothing stopping you from converting a location like “Mexico City, Mexico” into a latitude longitude coordinate.
Whatever you choose, just make sure the wiggle room is acceptable for the application and the casting is fast enough.