Elevator Pitch
I want us to introduce a way to leverage Rails’ knowledge of how to create attributes for form input elements and pair that with ActiveRecord agnostic markup created using tag helpers, partials, View Component or Phex or even Web Components. I believe this could bring a new wave of beautiful components flooding our ecosystem!
Abstract
This post is a proposal to add a new set of public helpers to FormBuilder
to provide ActiveRecord/Model-compatible attributes to create custom form input elements. I created a working prototype of this proposal and wrapped it in a gem to prove the proposal’s viability and discuss it here.
A poposed method like text_attribute_builder
returns a hash of HTML attributes that make input elements compatible with ActiveRecord/Model:
class MaterialFormBuilder < ActionView::Helpers::FormBuilder
def text_field(method, options = {})
# This is one of the new methods I'm proposing!
attribute_builder = text_field_attribute_builder(method, options)
html_attributes = attribute_builder.html_attributes
# => { id: user_name, name: "user[name]", value: "Julian"}
# this is used to build a custom form element.
# In this case, using Material Design Web Components
@template.content_tag("md-text-field", nil, html_attributes)
end
end
This would return the following markup:
<md-outlined-text-field type="text" name="user[name]" id="user_name" label="name"><md-icon slot="trailing-icon">person</md-icon></md-outlined-text-field>
And, in the case of Material design, return input fields that look like this:
More of a visual person? You can see this in action in the interactive prototype I’ve built to present this proposal. I really encourage you to play with it!
Context
The FormBuilder
class has been the primary mechanism for engineers to encapsulate common styles for form input elements for many years now.
class ExampleFormBuilder < ActionView::Helpers::FormBuilder
def text_field(method, options = {})
options.merge!(class: "form-input")
super(method, options)
end
end
Under the hood, Rails has classes that map to each of the input elements we use, for example the TextField
tag class. These helper classes have 2 responsibilities:
- Process the
object
of the form (e.g.User
), the method of the helper (e.g.name
) and other elements to provide a set of HTML attributes that make it compatible with Action Pack (i.e. Controllers) and Active Record. - Render the actual HTML i.e.
<input type="text" id="user_name" name="user[name]" value="Julian">
Problem
Showcasing the whole problem here would make this section too long. I have written in detail about this in a series my blog (with a lot of code examples). I’ll be linking to it throughout this section.
Creating anything more complex than this simple example becomes very cumbersome.
Recreating the element pictured above requires several layers of markup in order to add an icon, a floating label, hints, errors etc. Doing so within the scope of the text_field
method requires complex usage of content_tag and concat.
class SomeCustomFormBuilder < ActionView::Helpers::FormBuilder
def text_field(method, **args)
@template.capture do
@template.concat super(attribute, args.merge(class: 'a long string of classes if you use tailwind'))
if some_var = args.fetch(:some_argument, nil)
@template.concat @template.content_tag(:p, some_var., class: 'another long string of classes')
end
end
end
end
Engineers can also delegate this responsibility to partials
class SomeCustomFormBuilder < ActionView::Helpers::FormBuilder
def some_custom_text_field(method, **args) # 👎
@template.render(
"some_custom_form_builder/some_custom_text_field",
locals: { form: self, method: method, args: args)
)
end
end
But this ends up forcing developers to prepend something to the method name to prevent clashes with the form builder’s default method; the partial needs to reference the original text_field
method from within..
<div>
<span>Some icon</span>
<%= form.text_field %>
<span><%= form.object.error_messages[method] %>
</div>
Prior art
Several solutions have been built in the past to try to overcome this problem:
- Simple Form. It creates a complete new form_builder helper and changes the signature from
text_field
tofield
which deviates from Rails’ default behaviour. - Known issues | ViewComponent. View Component states in their documentation that the gem is not natively compatible with form_for. They have released compatibility patches so a
FormBuilder
instance can be passed around but couples the gem to the builder.
The vision
I envision being able to create and distribute ActiveRecord/Model and ActionPack agnostic form input elements that can receive any attributes. These can be Web Components, Partials, ViewComponents or Phlex Components. To make them work in Rails, I believe the framework can provide those attributes.
Solution
I propose creating a set of helpers that allow engineers to leverage Rails’ knowledge about ActionPack/ActiveRecord-compatible attributes but leave it up to developers to create newer HTML elements that can create markup in any way like using ViewComponent, Web Components, Rails helpers or even partials.
Even though I’m proposing helpers, I’m open to other mechanisms to fulfil the primary objective of this pitch.
I have already started this process by creating a gem called ActionView AttributeBuilders. This gem is an extraction from a Rails fork that I have been working on.
The process so far
The process has been to split up tag helper classes into two separate ones: The AttributeBuilder
and the Tag
.
As an example:
The helper inside for the FormHelper
goes from this:
def text_field(object_name, method, options = {})
Tags::TextField.new(object_name, method, self, options).render
end
To this:
def text_field(object_name, method, options = {})
attribute_builder = AttributeBuilders::TextField.new(object_name, method, self, options)
html_attributes = attribute_builder.html_attributes
# => { id: user_name, name: "user[name]", value: "Julian"}
Tags::TextField.new(attributes: html_attributes, object: attribute_builder.object, method_name: method, template_object: self).render
# => <input type="text" id="user_name" name="user[name]" value="Julian">
end
So far I have split most tags already in the fork I’m working on and all tests for helpers pass without any issues.
Remember: At this stage these are still private APIs in Rails.
Current status
So far these are all internal, private classes of the framework. I have turned these into helpers in the published gem so developers can use them to create their own markup:
module AttributeBuildersHelper
def text_field_attribute_builder(method, options)
ActionView::Helpers::AttributeBuilders::TextField.new(object_name, method, @template, options)
end
end
class MaterialFormBuilder < ActionView::Helpers::FormBuilder
def text_field(method, options = {})
# This is one of the new methods I'm proposing!
attribute_builder = text_field_attribute_builder(method, options)
html_attributes = attribute_builder.html_attributes
# => { id: user_name, name: "user[name]", value: "Julian"}
# this is used to build a custom form element.
# In this case, using Material Design Web Components
@template.content_tag("md-text-field", nil, html_attributes)
end
end
Possibilities
The Javascript ecosystem has had a huge aesthetic advantage over frameworks like Rails. From the get go, libraries like React developed a delivery mechanism for complete design libraries through components that make any app look good with very little work. With this project, (or something similar to it), we can overcome the last hurdle for this to happen in the Rails world.
This can enable things like:
- Gems for Web component libraries. Imagine a Material design gem that you could drop in and have a Material-looking Rails app in no time
- Tailwind Form input elements
- New open source component libraries
- Paid products of component libraries
All fully compatible with Rails forms.
I have created a couple of examples in the interactive playground for this project that use Material Design and Shoelace web components. Be sure to check it out since this project is ultimately a visual one. They still have a few issues here and there but it’s less about the attribute builders part and more about the implementation of the form builder being incomplete. It’s just an example more than anything.
Final thoughts
This is my first time doing a proposal this big to Rails so I don’t really know how the process goes. Any advice and help is greatly appreciated!
I am not really married to any of the class or helper names or even specific details of the implementation. I do believe strongly in the idea of making the attribute-building part of helpers a public interface that developers could use.
If we were to go forward I believe these are the steps to follow:
- Finish splitting all form helpers. (At the time of writing I’m missing maybe 5). At this point these would still be private classes in Rails
- Wrapping attribute builders in helpers. Still as private methods
- Create tests for the helpers and document them. This would make them public and ready for release. 3.1. These tests would overlap with the current ones that test attributes and markup at the same time. This can probably also be split and simplified. This is a ton of work
Some other thoughts:
- ActionView has a lot of helpers and they often call twin helpers in different modules. Stuff like, a form builder helper that calls a tag helper of the same name. This makes it particularly hard to reason about and to figure out where stuff lives and where should refactors be done. There’s also
form_helper
,form_tag
helper and I’m not quite sure how deep the rabbit hole goes some times. - As I work more and more in this project I start to feel like it may be easier to write it from scratch but then I see all of the edge cases in tests and then I don’t feel like it’s easier to rewrite anymore . I bounce between refactor and green-field every few days at this point.