Humane Rails Forms

A person shaking their first in disgust at the forms at their bank's website.
Image by Annie Ruygt

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.

Date picker in Safari

And Chrome.

Date picker in 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?

Form that accepts inputs like "Last week" for a date and "124.99/2" for a number

That’s a little better! When we save it we get what we’d expect: a date from last week and the calculated cost.

Computed values are persisted to the database when forms are saved

Let’s try creating an expense from 900 days ago, which is outside of the “90 days ago” policy in our expense account:

Original values are preserved if there's a model validation error

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.

Fly.io ❤️ Rails

Fly.io is a great way to run your Rails app close to your users. It’s really easy to get started. You can be running in minutes.

Deploy a Rails app today!

“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 a URL object, set URI#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.