The Potential birth of AttributeBuilders

In the previous article, I said I believe splitting up the responsibility of building attributes from the actual rendering of HTML can open up many interesting possibilities. But the work to do so might not be that simple. I've been working on this on and off for the past few months but doing so on my own started feeling like a bad idea; it may not be as good an idea as I think it is and I'm sure I'm losing the potential of getting other people to help and make the idea better.

This article is the way for me to share the actual code I've been writing, share the lessons I've learned along the way and hopefully get some feedback. It's also a way to showcase the process of what it would take to turn a historically private Rails API into a public one.

Why do this?

The purpose

One of the things that I like the most about the JavaScript ecosystem is the amount of detail they've put into building components for forms and how easy it is to plug them into a React app and have a beautiful, interactive experience out of the box.

In Rails (or in Ruby) we don't have that. Not in a way that feels native to us.

My purpose with this project –in this shape or another– is to unlock that potential. To provide developers with a better, carefully thought experience to create amazing form elements that integrate perfectly with Rails. Because that's Rails' superpower; the magic that comes from advanced abstractions that enable us to create great software.

The Goal

The goal is to provide an abstraction that knows about the ins and outs of the attributes that form elements need to interface seamlessly with Rails. In other words, let Rails provide the smarts for values, ids, names etc. and let developers leverage that to create their own components.

The structure of helpers

There are several moving pieces when it comes to rendering and testing helpers. The folder structure gives us the first clues. What we care about the most is the form_helper.rb module and the classes inside the tags folder which handle all of the creation of HTML tags and their attributes.

actionview/
  helpers/
    tags/
      base.rb
      text_field.rb ⚡️
      [...]
    form_helper.rb ⚡️

The form_helper.rb module defines all the methods we use when building forms like text_field (form.text_field :title) or number_field (form.number_field :age). And in turn, they use tag classes to render the HTML. Here's a reminder:

def text_field(object_name, method, options = {})
  Tags::TextField.new(object_name, method, self, options).render
end

As we saw in the previous article, a lot of the heavy lifting is defined in the helpers/base.rb class and all of its associated modules:

Helpers::ActiveModelInstanceTag
Helpers::TagHelper
Helpers::FormTagHelper
FormOptionsHelper

Testing

All of the classes inside the tags folder are tagged with a special comment:

# :nodoc:

In Rails, this means that this class is private and meant only to be used internally by the framework and that it can change without giving notice to anyone. Classes tagged with this have no documentation, which further signals to developers this shouldn't be used. Just like private methods, these "private classes" in this case are not tested directly. Instead, they're tested through whoever implements them, which in our case is the form_helper.

Let's take the Tags::TextField class as an example. There is no test for this class, but its behaviour is tested through several tests for the form_helper.rb module: form_helper_test.rb (check some of these tests here). It has 19 tests to be more exact. Here's an example test for reference:

def test_text_field_placeholder_with_string_value
  I18n.with_locale :placeholder do
    assert_dom_equal(
      '<input id="post_cost" name="post[cost]" placeholder="HOW MUCH?" type="text" />', 
      text_field(:post, :cost, placeholder: "HOW MUCH?"))
  end
end

Phase 1: Splitting helpers (ongoing)

This is by no means a finalised proposal. Everything can be changed and challenged. This idea is still in flux!

So, what are the actual steps needed to take, for example, a Tags::TextField helper and turning it into two classes with distinct responsibilities? Well, I've done quite a bit of that already. Visit this fork of Rails over at my GitHub account (the branch is names: separate_tag_helpers_responsability:

https://github.com/pinzonjulian/rails/tree/separate_tag_helpers_responsability

(If you want to follow in more detail what I've done, check each commit here)

The process is more or less simple; I'm not creating something entirely new for the framework. I'm just refactoring private internals in the hopes of making them public at some point.

The birth of AttributeBuilders

The links in the following section point to the fork I'm working on so be sure to click and take a look!

I went for AttributeBuilders as the name for the classes responsible for all the logic behind the different attributes or options needed to build a tag. The responsibility of these classes is to output a hash with all the options for each tag. So to create an <input type="text" name="article[title]" id="article_title" value="Hello, World!"> the output would be something like this:

{
  type: "text",
  name: "article[title]",
  id: "article_title",
  value: "Hello, World!"
}

Just as Tags have a Base they inherit most of their behaviour from, AttributeBuilders (folder) have a Base class too to handle all the heavy lifting.

The first AttributeBuilder: Text Field

A reminder the code you see is a work in progress and has a few rough edges and inconsistencies still.

I started with the TextField Attribute Builder for several reasons: first, it's probably one of the easiest tags to work with (compared to something like collection_check_boxes). Second, it turns out that TextField is the basis for a bunch of other helpers like number, email, password etc.

Doing this meant copying the Tags::TextField class and stripping it off its rendering responsibility, leaving only the options-building one. Here's a simplified preview (Click here to see the complete one AttributeBuilders::TextField):

class ActionView::Helpers::AttributeBuilders::TextField < Base # :nodoc:
  include Tags::Placeholderable

  def html_attributes
    options = @options.stringify_keys
    options["size"] = options["maxlength"] unless options.key?("size")
    options["type"] ||= field_type
    options["value"] = options.fetch("value") { value_before_type_cast } unless field_type == "file"
    add_default_name_and_id(options)

    return options
  end
end

Moving all of this code meant that the Tags::TextField class also needed to change to only have an HTML rendering responsibility. This is how it ended up looking:

module ActionView
  module Helpers
    module Tags
      class TextField < RendererBase
        include Helpers::ActiveModelInstanceTag, Helpers::TagHelper

        def render
          tag("input", @attributes)
        end
      end
    end
  end
end

(Note that the parent is called RendererBase class. This is a temporary Base class I created to support the migration. This class needs some work and should eventually be renamed back to Tags::Base)

Finally, let's put this to use in the FormHelper module in the text_field method:

def text_field(object_name, method, options = {})
  attribute_builder = AttributeBuilders::TextField.new(object_name, method, self, options)
  html_attributes = attribute_builder.html_attributes

  text_field_element = Tags::TextField.new(
    attributes: html_attributes, 
    object: attribute_builder.object, 
    method_name: method, 
    template_object: self
  )

  text_field_element.render
end

In plain English, this is:

  • Initializing the AttributeBuilders::TextField class

  • building the attributes calling the html_attributes method on attribute_builder

  • Initializing the Tags::TextField class

  • rendering the HTML calling the render method on the text_field_element

Since the behaviour didn't change at all, all the tests pass!

(Isn't that more code than before? Yes. Is that worse? I don't think so. Maybe there's an extra missing abstraction there but the possibilities this opens up are huge).

What I've done so far

TextField was the first one but I've already tackled a bunch of these:

  • check_box

  • color_field

  • date_field

  • datetime_field

  • datetime_local_field

  • email_field

  • file_field

  • hidden_field

  • number_field

  • password_field

  • radio_button

  • range_field

  • search_field

  • text_area

  • text_field

  • time_field

  • url_field

  • week_field

It looks like an impressive, long list but most of these are children of TextField which made it super easy once I figured out the basics. There are about 9 or so to go (like CollectionSelect, GroupedCollectionSelect and others) which might present some challenges.

Phase 2: Creating a public interface

If this is a good idea and the Rails Core team accepts it and merges it, then we can move on to the next Phase.

When Phase 1 finishes nothing will have changed for developers yet. The interfaces would be the same and no new public APIs would have been created. The next step would be to turn these private, #:nodoc classes into public ones which means two things: testing them directly and documenting them thoroughly.

On testing

There are two things to do here. The first is to create tests for the new classes; this means turning the current tests from ones that check for HTML to ones that check the proper hashes are built. Once those tests are converted, we will need to figure out what a test for the rendering class looks like (I don't yet have an answer for this one).

Calling all developers!

Here's where you come in.

I believe in this idea –or some form of it– but I need opinions and challenges. I need other perspectives to make this even better. What do you think? How would you tackle this problem? How would you tackle the process of contributing this to Rails?

For example, I've thought of making this a gem, independent of Rails that patches the Tag classes and the FormHelper. Doing so would allow us to play with it safely in other code bases, maybe create an example design system with it or something like that. I started it in the Rails codebase because I needed the tests to make sure I wasn't breaking anything but maybe once it's done, it could be extracted into a Gem.


That's all for this series. I really hope something comes out of this because I think this is one of the most interesting areas in Rails right now. With the newly found love for simpler applications, small-but-mighty teams, the innovation brought by component libraries like ViewComponent and Phlex, and the energy brought by HTML over the wire and the return to glorious server-side rendered HTML, I believe there's no better time to keep pushing forward. To keep making Ruby and Rails better to enable a future with better, simpler and more elegant applications that solve real-world problems.