[proposal] ActionView AttributeBuilders for FormBuilders

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:

  1. 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.
  2. 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 to field 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:

  1. 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
  2. Tailwind Form input elements
  3. New open source component libraries
  4. 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:

  1. 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
  2. Wrapping attribute builders in helpers. Still as private methods
  3. 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 :joy:. I bounce between refactor and green-field every few days at this point.
10 Likes

I’m just a bystander here, but I’m not understanding what you are proposing, and I think it’s because there isn’t any HTML in this post that demonstrates what would be produced. The class at the top of this post creates (I think) a <md-text-field> element (which I don’t see defined anywhere), and the screenshot shows two text fields, but there is no HTML to show what is the desired output. I clicked on the interactive prototype, but it doesn’t seem to be interactive - it seems to be a blog post instead? I am on Safari on macOs, so I guess it’s possibile there is some JavaScript not working?

If this would be obvious to rails-core, please ignore me, but as a non-core dev who likes the idea of improving Rails form builders, I was not able to follow the specifics.

1 Like

Thanks for your reply! I will edit it and make it better.

In the meantime, the interactive prototype has a home page with an explanation of how yo navigate it. Manly, click the links in the navbar for Material or Shoelace and interact with the form. It does use Javascript (both web components need JS + stimulus for the tabs and turbo for updates) so make sure to have JS activated.

I’m on the latest safari mobile right now and it works. Happy to debug it if you’re running into any specific issues.

I’ve tried to be more explicit and clarified it a little bit more. Can I get a second read from you please? I"ll greatly appreciate it!

We are dealing with the same issue in our app. We are switching from using React on frontend to full rails with turbo. We really enjoyed the ability to create re-sharable components and use them for consistent UI across the app with React. We wanted to maintain this component structure in Rails using ViewComponent. However, trying to get forms to play nicely with this is difficult in Rails (even with ViewComponent lib). We are also using material 3 from Google which has opted for web components and so we cannot change the markup for form fields to custom web components. We don’t want to lose the built in functionality that rails provides (e.g. translations, form names mapped to controller params, etc…). But there is no where to currently plugin to the framework smoothly to get form helpers to render custom web components for form fields.

1 Like

Thanks, it makes slightly more sense - I guess I didn’t realize that the interactive prototype required clicking the text in the upper-left of the page (as context, I don’t know what “Material” or “Shoelace” are, so I guess I figured they were other sections of the blog? Maybe I am too dense :).

I’m still not 100% sure I’m following, but I think you want to use the exact same HTML attributes Rails uses for <input type="text" ...> but for a web component?

Could you create something like Tags::WebComonent that inherits from ActionView::Helpers::Tags::Base instead? That has all the internals that generate the attributes using the Rails conventions (and dom_id can be used to generate the ids conventionally)

This is the exact problem this proposal is about. If you and your team have the time, I would really appreciate if you could give the gem a try and report back your findings. It would help enormously in assessing the usefulness of it.

It’s not just for web components. The objective is to decouple the rendering part of the Tag class from the attribute building side of it.

This allows us to create components in any shape or form (using View Component, Phlex, partials or web components) that do not meed to know anything about Rails’ internals.

As things are right now, it’s impossible to build a ViewComponent for a Text Field without it depending on an instance of Form Builder. And even then, there are compatibility issues with that. This shouldn’t be the case in my opinion. A component (i.e. markup for a complex input element) should be able to exist without being tied up to the internals of Rails.

The Tags::Base class you mentioned is a Private implementation in Rails so you shouldn’t inherit from it. Also, if you create a class that inherits from this, it is yet again completely coupled to things like the FormBuilder or the template where markup is being rendered.

This is partly what I want to change; to create public interfaces for this purpose.

The ideal/utopic outcome of this is that we could even have components (i.e. Ruby classes) that could work in any framework: Rails, Sinatra, Hanami etc. If each of those provides a public interface to create attributes based on its conventions, then the actual markup can come from anywhere.

2 Likes