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
classbuilding the attributes calling the
html_attributes
method onattribute_builder
Initializing the
Tags::TextField
classrendering the HTML calling the
render
method on thetext_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.