[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.
11 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

I don’t understand people from time to time.

Based on the problem you stated, you just need partials that are agnostic to Rails’ form helpers to implement some shared HTML elements.

That is precisely what partials do.
Based on the HTML you provided, your partial should look like this:

__my_partial.html.erb

<div>
  <span>Some icon</span>
  <% render_input.call %>
  <span><%= error_message %></span>
</div>

And you can use it like this (also based on your example):

<-- assumed we are within form_with using f as a block parameter -->
<%= render partial: 'my_partial', locals: {
  error_message: f.object.error_messages[method],
  render_input: lambda do %>
  <%= f.text_field %>
<% end } %>

What you can’t do with partials is create totally wild elements, not standard HTML input elements.
I can see some cases you might want to do your way, but partials should cover a very high portion of cases in existence.

If you have more complex situations or examples, try posting them here.

I don’t understand people from time to time.

Statements like this one are not very helpful for a constructive discussion, San. It’s unnecessary, rude and diminishes the credibility of your message.

Now, on to the technical stuff.


Imagine if you were to distribute that partial as a library. With this strategy you’re not shipping a complete input field component because it opens up the possibility for a user to mess things up. I invite you to visit the interactive demo (also linked above) for a really good example that uses web components. It’s something that’s practically impossible to do and distribute now in Rails. You can also check this WIP branch for the playground that makes use of attribute builders with ViewComponent.

Your idea is also completely tied up to FormBuilders which is not the idea for this project. This idea is an exploration to create form elements that do not depend on an instance of FormBuilder.

Statements like this one are not very helpful for a constructive discussion

Beat me to it, but: +1.


Sorry, I haven’t replied before, despite having read through this thread a couple of times, because I don’t feel like I’m fully following the direct before/after. As you note, this generally seems to be the problem that FormBuilder is intended to help with.

(Please pretend that last time I felt deeply immersed in the options for frontend libraries, jQuery was the new kid on the block.)

Is most of the problem the fact that some frontend systems (Material here) specifically use HTML-identical attributes on newly-named tags, and so you want to be able to lean on our built-in logic to get from model+kwargs to that set of attributes, but then drop them onto a different tag name? (Sorry if that sounds reductive – I’m just trying to get a handle on the problem space.)

Outside of that situation of wanting to rely on the built-in attribute construction, for more general HTML building, or custom fields that need differently-shaped attributes, I would have imagined FormBuilder to already be a reasonable API.

Statements like this one are not very helpful for a constructive discussion, San. It’s unnecessary, rude and diminishes the credibility of your message.

It was meant to be taken literally, and I still don’t see how it can be rude. Maybe it is a cultural difference. (You could elaborate on it a little, maybe.)

On the point of the interactive demo, this is how I see it at first:

  • The tabs at the top are parts of your website.
  • The UI forced me to scroll down to read the details.
  • After the reading, I don’t see where the demo is. (I am at the bottom of the page; the tabs meant to be demos are long gone.)

Hence, I said I did not understand.

What I see now after reading more and actually seeing the demo is that you want to ship views as a library. Those are things I never thought of doing before; given the state of the frontend ecosystem today, I would prefer customizability over packageability. (reusable & sharable at the same time)

My take is that you envisioned that form components would be packaged as a gem if Rails’ form builder exposed the public API you described.
If that sums it up, I think you lost some of the readers because the code came up too soon before conveying the clear objectives.

Reusability alone is there; the library adds sharability.

I read this differently, just for a devil’s advocate point of view. As a non-neurotypical person, I often miss cues that others give, and this statement could be taken as an admission of the same by the OP. Just saying, it’s not necessarily a knock against other people, but perhaps a self-deprecating way to defuse the level of emotion in the discussion.

Walter

Thanks for your answer, Matthew.

It’s been kind of hard to convey the message for this proposal and I hope that discussing it further will make it clearer. Here’s an example that will hopefully help.

Outcome

Imagine you want to create this component:

This component has multiple elements:

  • Label
  • input field
  • icon (can be leading or trailing)
  • hint

And some behaviour when invalid:

  • hint now becomes error
  • color changes

In a template

Writing this markup in a plain html.erb template would look something like this (this is a pseudocode example of how that would look):

<%= form_with model: @user do |form| %>
  <div class="form-field">
    <%= form.label :name %>
    <div class="input">
      <span><%= icon(:person) %></span>
      <%= form.text_field :name %>
    </div>
    <%= hint_or_error_for(@user, :name) %>
  </div>
<% end %>

Options to reuse it

With a partial

You could do something like this

<%= form_with model: @user do |form| %>
  <%= render "forms/text_field", locals: { form: form, method: :name, icon: { name: :person, position: :leading } } %>
<% end %>

But you would lose the ergonomics of Rails forms. You would no longer call form.text_field :name but call render on a partial

In a FormBuilder

This can be translated to a FormBuilder (again, some pseudo code)

class CustomFormBuilder < ApplicationFormBuilder
  def custom_text_field(method, options = {})
    @template.tag.div(class: "form-field") do
      safe_join {
        label(method)
        tag.div(class: "input") do
          safe_join {
            tag.span(icon(:person))
            text_field(:person)
          }
        end
      }
    end
  end
end

This option is doable but very cumbersome to write. You might opt to do the HTML side of things in a partial:

class CustomFormBuilder < ApplicationFormBuilder
  def custom_text_field(method, options = {})
    @template.render("forms/text_field", method: method, options: options)
  end
end

This abstraction into a partial can also be done with ViewComponent, Phlex or even a web component.

You would then use this like so:

<%= form_with model: @user do |form| %>
  <%= form.custom_text_field(:name, icon: { name: :person, position: :leading }) %>
<% end %>

These two options have an issue though: you cannot name the method just text_field; you need to always prepend something to it, in this case: custom_. That’s because if you name it just text_field the method will just call itself and run into an endless loop and a stack overflow. FormBuilder#text_field is in a way, reserved to be the name of a raw input field.


Why do all of this instead of just breaking out of FormBuilders and write your won HTMl by hand?

Well, ActionView/FormBuilder has all the knowledge of Rails’ conventions to compute the attributes of an input field; like conventional IDs, names, values, translations etc. The problem is that this computation of the attributes is intermingled with the construction of the markup. If as a developer I want to use just the attributes but come up with my own rendering (i.e. the examples above) I have to give up the nice APIs Rails provides and riddle the code with a bunch of prefixes in every method.

What I envision is for us to be able to free up the method name (i.e. text_field) and split its current responsibility in two: the attribute builder side of things and the html rendering side. This way, we can let developers swap the HTML renderer with anything they like: a partial, a ViewComponent, a Phlex Component or a custom-element (like the material example you mentioned).

class CustomFormBuilder < ApplicationFormBuilder
  def text_field(method, options = {})
    # get attributes for the text field
    input_html_attributes = text_field_attribute_builder(method, options).html_attributes
    # Option 1: render using a partial
    @template.render("forms/text_field", input_html_attributes)    
    # Option 2: render using a ViewComponent
    @template.render TextFieldComponent.new(html_attributes)
    # Option 3: render a web component
    @template.tag.material_text_field(html_attributes)
  end
end

I believe this can unlock something very special; the ability to create and distribute component libraries for Rails; which is probably one, if not THE framework’s biggest gaps at the moment in the front-end aspect of it.

If we can provide library authors with a way to leverage Rails’ conventions for attributes they can focus entirely on the visual aspect of form elements. They will be able to keep the same ergonomics and language that Rails developers have used for decades but provide much richer UIs.

Thank you for sharing this…

1 Like