Rails+TailwindCSS: adding "required" to form labels (and how to find the solution by yourself)

sowenjub

Arnaud Joubay

Posted on July 10, 2020

Rails+TailwindCSS: adding "required" to form labels (and how to find the solution by yourself)

This post addresses a simple need: adding a "required" text next to any form field label that is… required.

But I wanted a solution:

  • using only TailwindCSS/TailwindUI existing classes
  • that can be reused easily
  • internationalization friendly (because we're using a word - required - and not the "*" sign as you often see)
  • that doesn't reinvent the wheel in some way
  • that looks like this

Alt Text

For the coder in a hurry, my solution

The gist of it is label_builder.translation.

= f.label :email, required: true, class: "block text-xxs uppercase text-gray-500" do |label_builder|
  = label_builder.translation
  span.ml-2.normal-case.text-orange-400.text-xxs.font-semibold= t("required")
  = f.email_field :email, autocomplete: false, required: true, class: "form-input mt-1 block w-full text-field focus:shadow-outline focus:border-green-300", placeholder: "richard@piedpiper.com"
Enter fullscreen mode Exit fullscreen mode

Note: I use the Slim template language instead of ERB; it's easier to read so I trust that even if you've never heard of it you won't be lost.

For the curious mind, how I got there and why

FAIR WARNING: the rest is not your typical technical article. You already have the solution above, the rest is about how I found the solution. So it's more a story than a guide.

Here I was setting up a simple profile form, adding the proper required: true attributes and looking at it when I realized: how the hell would a user know which fields are required and which aren't?

Like anyone, anytime I see a form, my mind looks for the easy way out and tries to figure out how to hit that submit button as quickly as possible.
This bland form triggered my fill or flight response and I knew I had to do something about it.

Starting point

With a bit of TailwindCSS, here is what it was looking like.

Alt Text

and the code

= f.label :email, required: true, class: "block text-xxs uppercase text-gray-500"
= f.email_field :email, autocomplete: false, required: true, class: "form-input mt-1 block w-full text-field focus:shadow-outline focus:border-green-300", placeholder: "richard@piedpiper.com"
Enter fullscreen mode Exit fullscreen mode

It's as basic as it gets: a label and an email_field with some classes.
I will skip the TailwindCSS explanations, just know that "form-input" comes from a plugin.

Goal check:

  • ✅ using only TailwindCSS/TailwindUI existing classes
  • ❌ that can be reused easily
  • ❌ internationalization friendly
  • ❌ that doesn't reinvent the wheel in some way
  • ❌ that looks like the cover image

Approach n°1: Add a "*" after the email

Often, forms signal that a field is required by appending an asterisk to the label title, which can be done with a bit of CSS.

  .required:after {
    content:" *";
  }
Enter fullscreen mode Exit fullscreen mode

I went to the TailwindCSS docs and couldn't find anything ready-made to handle this.
Sure, I could add it to my stylesheet files, but I already had another idea in mind.
I played a little bit with Basecamp recently as I watched the On Writing Software (well?) videos by DHH. And it reminded me that using the word "required" instead of a simple "*" sign is great to improve clarity.

Approach n°2: Appeal to humanity

This confusing title is a bad pun inspired the method we'll be using in this approach: Model.human_attribute_name.

Since label can take a block to render, it's easy to come up with a first solution.

So I went from

= f.label :email, required: true, class: "block text-xxs uppercase text-gray-500"
Enter fullscreen mode Exit fullscreen mode

to

= f.label :email, required: true, class: "block text-xxs uppercase text-gray-500" do
  | Email
  span.ml-2.normal-case.text-orange-400.text-xxs.font-semibold required
Enter fullscreen mode Exit fullscreen mode

OK. It looks like the final result, but we're not quite there yet, because both "Email" and "required" are hardcoded.

Goal check:

  • ✅ using only TailwindCSS/TailwindUI existing classes
  • ❌ that can be reused easily
  • ❌ internationalization friendly
  • ❌ that doesn't reinvent the wheel in some way
  • ✅ that looks like the cover image

The "required" text doesn't need much talking about. We just have to replace it with = t("required") and add a translation somewhere, probably in config/locals/en.yml since it will be pretty generic.

en:
  required: required
Enter fullscreen mode Exit fullscreen mode

But what about the email? I needed a solution that would be a bit more generic.
The standard way to look up translations for any attribute is Model.human_attribute_name(:attribute), so I did just that.

= f.label :email, required: true, class: "block text-xxs uppercase text-gray-500" do
  / 👍 Not horrible
  = f.object.class.c(:email)
  span.ml-2.normal-case.text-orange-400.text-xxs.font-semibold= t("required")
  = f.email_field :email, autocomplete: false, required: true, class: "form-input mt-1 block w-full text-field focus:shadow-outline focus:border-green-300", placeholder: "richard@piedpiper.com"
Enter fullscreen mode Exit fullscreen mode

At this point, I am relying on common techniques. A little bit unsatisfying but it gets the job done.

What did I find unsatisfying, you may wonder?

Generally speaking, rails take care of many intricacies you might not think of. When you're replicating part of a method (in our case the part that takes a symbol - :email - and turns it into text - Email -), it's highly likely you're forgetting edge cases or oversimplifying.
It's like using a steering wheel versus ropes tied to the wheels. Sure, it works, you have a direct grip on things, but there's a reason we built an intermediary thingy.

In particular, by doing our own thing instead of relying on rails' wisdom, we're missing out on lazy lookup cleverness.
And in our case, by using Model.human_attribute_name directly, we are reinventing the wheel.

Goal check:

  • ✅ using only TailwindCSS/TailwindUI existing classes
  • ✅ that can be reused easily
  • ✅ internationalization friendly
  • ❌ that doesn't reinvent the wheel in some way
  • ✅ that looks like the cover image

Final approach

Where to go from there? I knew that the label helper was handling the translation at some level, and I wanted to know if you could tap into it.

There was two way to deal with this. The easy way, and the way I did it because… I got carried away.

Let's start with the laborious (but still interesting) way

The easiest way to know how the label helper handles the translation is to look at the source code
Using Dash, I opened the rails source code and found this

def label(method, text = nil, options = {}, &block)
  @template.label(@object_name, method, text, objectify_options(options), &block)
end
Enter fullscreen mode Exit fullscreen mode

I clicked the label call and looked at the definitions found by Github's code navigation (I only discovered recently that Github lets you do that, so I'm mentioning it here in case you didn't know about).

Alt Text

The method's signature of the first row seems to match the one used before, so I followed it and found this, which doesn't do much:

def label(object_name, method, content_or_options = nil, options = nil, &block)
    Tags::Label.new(object_name, method, self, content_or_options, options).render(&block)
end
Enter fullscreen mode Exit fullscreen mode

From there, I followed Tags::Label and finally arrived on the LabelBuilder
Here I started to skim through the code, but I didn't have to go far because, at the very top, I saw a promising def translation.

I went back to the code, added a parameter to the block call that I felt should be named label_builder, and called its translation method.

= f.label :email, required: true, class: "block text-xxs uppercase text-gray-500" do |label_builder|
  / ✨ True magic
  = label_builder.translation
  span.ml-2.normal-case.text-orange-400.text-xxs.font-semibold= t("required")
  = f.email_field :email, autocomplete: false, required: true, class: "form-input mt-1 block w-full text-field focus:shadow-outline focus:border-green-300", placeholder: "richard@piedpiper.com"
Enter fullscreen mode Exit fullscreen mode

This translation is closer to the template engine than the previous solution.

The easy way

Before we go further, here is the easy way to find that same method in less time.

You just have to a) use the console

= f.label :email, required: true, class: "block text-xxs uppercase text-gray-500" do |label_builder|
  - console
Enter fullscreen mode Exit fullscreen mode

b) load the page in your browser, wait for the console to appear, and type label_builder.methods to return the list of the names of methods of our label_builder: :translation is the very first one.

Alt Text

Why didn't I think of that? Force of habits mostly.
I've found a lot of solutions recently by reading code so it was my first instinct. Also, I didn't think it would be that easy, ie that the LabelBuilder would just expose the translation.
But I must admit that the prospect of reading code and learning a thing or two because of it was also appealing. I find that it's always worth my time.

Why is it better and what did we learn?

Remember when I told you that we were missing out on lazy lookup cleverness?
You can reveal it using i18n-debug.

The previous solution using human_attribute_name looks for this key:

en.activerecord.attributes.contact.email
Enter fullscreen mode Exit fullscreen mode

Our new solution first looks for one specific to labels and only falls back on the model one if it's nil.

en.helpers.label.contact.email
en.activerecord.attributes.contact.email
Enter fullscreen mode Exit fullscreen mode

This is way more satisfying 😌 and the end of the original article.

I wanted to not only share my solution but also the tools I use as well as a way to go beyond duck-taping (which to me is any approach up to - and including - the human_attribute_name approach).

At this point you know how to add a "required" text to form labels:

  • ✅ using only TailwindCSS/TailwindUI existing classes
  • ✅ that can be reused easily
  • ✅ internationalization friendly
  • ✅ that doesn't reinvent the wheel in some way
  • ✅ that looks like the cover image

For the Stakhanovites, going further with a Form Builder

I didn't plan to go that far initially, but I couldn't resist once I thought about it 😬

If you're going to do that often, you'll probably want to start customizing form builders in order to make all of this even more reusable, write less code and get even closer to vanilla rails.

Indeed, with a form builder we can simply write something like this:

= f.label :email, required: true, class: "block text-xxs uppercase text-gray-500", required_class: "ml-2 normal-case text-orange-400 text-xxs font-semibold"
= f.email_field :email, autocomplete: false, required: true, class: "form-input mt-1 block w-full text-field focus:shadow-outline focus:border-green-300", placeholder: "richard@piedpiper.com"
Enter fullscreen mode Exit fullscreen mode

If you compare it to our starting point, you'll see that there is only one difference: an extra required_class.

Alt Text

Now, this is 😎.

What would that form builder look like? Here's my take on this. It's the first form builder I write, so if you have more experienced and notice something weird please do tell.

# app/form_builders/requiring_form_builder.rb
class RequiringFormBuilder < ActionView::Helpers::FormBuilder
  def label(method, text = nil, options = {}, &block)
    text_is_options = text.is_a?(Hash)
    required = text_is_options ? text[:required] : options[:required]
    if required
      required_class = text_is_options ? text.delete(:required_class) : options.delete(:required_class)
      super(method, text, options) do |label_builder|
        @template.concat label_builder.translation
        @template.concat @template.content_tag(:span, I18n.t("required", scope: :helpers), class: required_class)
        @template.concat(@template.capture(label_builder, &block)) if block_given?
      end
    else
      super(method, text, options, &block)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

There are two other things required for this to work.

First, the form_with declaration must declare the builder:

= form_with model: @user, builder: RequiringFormBuilder do |f|
Enter fullscreen mode Exit fullscreen mode

Second, you might have noticed that I use I18n.t("required", scope: :helpers) and not I18n.t("required") as we did before because it seems more appropriate to put that under the helpers namespace (in my case in a file called config/locals/helpers/en.yml). So you need to move that in the proper translation file, or at the minimum to change

en:
  required: required
Enter fullscreen mode Exit fullscreen mode

into

en:
  helpers:
    required: required
Enter fullscreen mode Exit fullscreen mode

I won't go into the details, but I'll point to two parts in the rails code that helped me write this code.

With this form builder, we can now simply add a required_class to any required label field.

If you enjoyed this article, you can follow me on Twitter @sowenjub.

💖 💪 🙅 🚩
sowenjub
Arnaud Joubay

Posted on July 10, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related