Super Scaffolding with Delegated Types

Introduction

In this guide, we’ll cover how to use Super Scaffolding to build views and controllers around models leveraging delegated types. As a prerequisite, you should read the native Rails documentation for delegated types. The examples in that documentation only deal with using delegated types at the Active Record level, but they lay a foundation that we won’t be repeating here.

Terminology

For the purposes of our discussion here, and building on the Rails example, we’ll call their Entry model the “Abstract Parent” and the Message and Comment models the “Concrete Children”.

One of Multiple Approaches

It’s worth noting there are at least two different approaches you can take for implementing views and controllers around models using delegated types:

  1. Centralize views and controllers around the Abstract Parent (e.g. Account::EntriesController).
  2. Create separate views and controllers for each Concrete Child (e.g. Account::MessagesController, Account::CommentsController, etc.)

In this guide, we’ll be covering the first approach. This might not seem like an obvious choice for the Message and Comment examples we’re drawing on from the Rails documentation (it's not), but it is a very natural fit for other common use cases like:

  • “I’d like to add a field to this form and there are many kinds of fields.”
  • “I’d like to add a section to this page and there are many kinds of sections.”

It’s not to say you can’t do it the other way described above, but this approach has specific benefits:

  1. It’s a lot less code. We only have to use Super Scaffolding for the Abstract Parent. It's the only model with views and controllers generated. For the Concrete Children, the only files required are the models, tests, and migrations generated by rails g model and some locale Yaml files for each Concrete Child.
  2. Controller permissions can be enforced the same way they always are, by checking the relationship between the Abstract Parent (e.g. Entry) and Team. All permissions are defined in app/models/ability.rb for Entry only, instead of each Concrete Child.

Steps

1. Generate Rails Models

Drawing on the canonical Rails example, we begin by using Rails' native model generators:

rails g model Entry team:references entryable:references{polymorphic}:index
rails g model Message subject:string
rails g model Comment content:text

Note that in this specific approach we don't need a team:references on Message and Comment. That's because in this approach there are no controllers specific to Message and Comment, so all permissions are being enforced by checking the ownership of Entry. (That's not to say it would be wrong to add them for other reasons, we're just keeping it as simple as possible here.)

2. Super Scaffolding for Entry

rails generate super_scaffold Entry Team entryable_type:buttons --skip-migration-generation

We use entryable_type:buttons because we're going to allow people to choose which type of Entry they're creating with a list of buttons. This isn't the only option available to us, but it's the easiest to implement for now.

3. Defining Button Options

Super Scaffolding will have generated some initial button options for us already in config/locales/en/entries.en.yml. We'll want to update the attribute name, field label (which is shown on the form) and the available options to reflect the available Concrete Children like so:

fields: &fields
  entryable_type:
    name: &entryable_type Entry Type
    label: What type of entry would you like to create?
    heading: *entryable_type
    options:
      "Message": Message
      "Comment": Comment

We will add this block below in the next step on our new.html.erb page so you don't have to worry about it now, but with the options above in place, our buttons partial will now allow your users to select either a Message or a Comment before creating the Entry itself:

<% with_field_settings form: form do %>
  <%= render 'shared/fields/buttons', method: :entryable_type, html_options: {autofocus: true} %>
  <%# 🚅 super scaffolding will insert new fields above this line. %>
<% end %>

This will produce the following HTML:

<div>
  <label class="btn-toggle" data-controller="fields--button-toggle">
    <input data-fields--button-toggle-target="shadowField" type="radio" value="Message" name="entry[entryable_type]" id="entry_entryable_type_message">
    <button type="button" class="button-alternative mb-1.5 mr-1" data-action="fields--button-toggle#clickShadowField">
      Message
    </button>
  </label>
  <label class="btn-toggle" data-controller="fields--button-toggle">
    <input data-fields--button-toggle-target="shadowField" type="radio" value="Comment" name="entry[entryable_type]" id="entry_entryable_type_comment">
    <button type="button" class="button-alternative mb-1.5 mr-1" data-action="fields--button-toggle#clickShadowField">
      Comment
    </button>
  </label>
</div>

4. Add Our First Step to new.html.erb

By default, app/views/account/entries/new.html.erb has this reference to the shared _form.html.erb:

<%= render 'form', entry: @entry %>

However, in this workflow we actually need two steps:

  1. Ask the user what type of Entry they're creating.
  2. Show the user the Entry form with the appropriate fields for the type of entry they're creating.

The first of these two forms is actually not shared between new.html.erb and edit.html.erb, so we'll copy the contents of _form.html.erb into new.html.erb as a starting point, like so:

<% if @entry.entryable_type %>
  <%= render 'form', entry: @entry %>
<% else %>
  <%= form_with model: @entry, url: [:new, :account, @team, :entry], method: :get, local: true, class: 'form' do |form| %>
    <%= render 'account/shared/forms/errors', form: form %>
    <% with_field_settings form: form do %>
      <%= render 'shared/fields/buttons', method: :entryable_type, html_options: {autofocus: true} %>
    <% end %>
    <div class="buttons">
      <%= form.submit t('.buttons.next'), class: "button" %>
      <%= link_to t('global.buttons.cancel'), [:account, @team, :entries], class: "button-secondary" %>
    </div>
  <% end %>
<% end %>

Here's a summary of the updates required when copying _form.html.erb into new.html.erb:

  1. Add the if @entry.entryable_type branch logic, maintaining the existing reference to _form.html.erb.
  2. Add @ to the entry references throughout. @entry is an instance variable in this view, not passed in as a local.
  3. Update the form submission url and method as seen above.
  4. Remove the Super Scaffolding hooks. Any additional fields that we add to Entry would be on the actual _form.html.erb, not this step.
  5. Simplify button logic because the form is always for a new object.

5. Update Locales

We need to add a locale entry for the "Next Step" button in config/locales/en/entries.en.yml. This goes under the buttons: &buttons entry that is already present, like so:

buttons: &buttons
  next: Next Step

Also, sadly, the original locale file wasn't expecting any buttons in new.html.erb directly, so we need to include buttons on the new page in the same file, below form: *form, like so:

new:
  # ...
  form: *form
  buttons: *buttons

6. Add Appropriate Validations in entry.rb

In app/models/entry.rb, we want to replace the default validation of entryable_type like so:

ENTRYABLE_TYPES = I18n.t('entries.fields.entryable_type.options').keys.map(&:to_s)

validates :entryable_type, inclusion: {
  in: ENTRYABLE_TYPES, allow_blank: false, message: I18n.t('errors.messages.empty')
}

This makes the locale file, where we define the options to present to the user, the single source of truth for what the valid options are.

TODO We should look into whether reflecting on the definition of the delegated types is possible.

Also, to make it easy to check the state of this validation, we'll add entryable_type_valid? as well:

def entryable_type_valid?
  ENTRYABLE_TYPES.include?(entryable_type)
end

I don't like this method. If you can think of a way to get rid of it or write it better, please let us know!

7. Accept Nested Attributes in entry.rb and entries_controller.rb

In preparation for the second step, we need to configure Entry to accept nested attributes. We do this in three parts:

In app/models/entry.rb, like so:

accepts_nested_attributes_for :entryable

Also in app/models/entry.rb, Rails will be expecting us to define the following method on the model:

def build_entryable(params = {})
  raise 'invalid entryable type' unless entryable_type_valid?
  self.entryable = entryable_type.constantize.new(params)
end

Finally, in the strong parameters of app/controllers/account/entries_controller.rb, directly below this line:

# 🚅 super scaffolding will insert new arrays above this line.

And still within the permit parameters, add:

entryable_attributes: [
  :id,

  # Message attributes:
  :subject,

  # Comment attributes:
  :content,
],

(Eagle-eyed developers will note an edge case here where you would need to take additional steps if you had two Concrete Children classes that shared the same attribute name and you only wanted submitting form data for that attribute to be permissible for one of the classes. That situation should be exceedingly rare, and you can always write a little additional code here to deal with it.)

8. Populate @entry.entryable in entries_controller.rb

Before we can present the second step to users, we need to react to the user's input from the first step and initialize either a Message or Comment object and associate @entry with it. We do this in the new action of app/controllers/account/entries_controller.rb and we can also use the build_entryable method we created earlier for this purpose, like so:

def new
  if @entry.entryable_type_valid?
    @entry.build_entryable
  elsif params[:commit]
    @entry.valid?
  end
end

9. Add the Concrete Children Fields to the Second Step in _form.html.erb

Since we're now prompting for the entry type on the first step, we can remove the following from the second step in app/views/account/entries/_form.html.erb:

<%= render 'shared/fields/buttons', method: :entryable_type, html_options: {autofocus: true} %>

But we need to keep track of which entry type they selected, so we replace it with:

<%= form.hidden_field :entryable_type %>

Also, below that (and below the Super Scaffolding hook), we want to add the Message and Comment fields as nested forms like so:

<%= form.fields_for :entryable, entry.entryable do |entryable_form| %>
  <%= entryable_form.hidden_field :id %>
  <% with_field_settings form: entryable_form do %>
    <% case entryable_form.object %>
    <% when Message %>
      <%= render 'shared/fields/text_field', method: :subject %>
    <% when Comment %>
      <%= render 'shared/fields/trix_editor', method: :content %>
    <% end %>
  <% end %>
<% end %>

We add this below the Super Scaffolding hook because we want any additional fields being added to Entry directly to appear in the form above the nested form fields.

10. Add Attributes of the Concrete Children to show.html.erb

Add the following in app/views/account/entries/show.html.erb under the Super Scaffolding hook shown in the example code below:

<%# 🚅 super scaffolding will insert new fields above this line. %>

<% with_attribute_settings object: @entry.entryable, strategy: :label do %>
  <% case @entry.entryable %>
  <% when Message %>
    <%= render 'shared/attributes/text', attribute: :subject %>
  <% when Comment %>
    <%= render 'shared/attributes/html', attribute: :content %>
  <% end %>
<% end %>

This will ensure the various different attributes of the Concrete Children are properly presented. However, the label strategy for these attribute partials depend on the locales for the individual Concrete Children being defined, so we need to create those files now, as well:

config/locales/en/messages.en.yml:

en:
  messages: &messages
    fields:
      subject:
        _: &subject Subject
        label: *subject
        heading: *subject
  account:
    messages: *messages
  activerecord:
    attributes:
      message:
        subject: *subject

config/locales/en/comments.en.yml:

en:
  comments: &comments
    fields:
      content:
        _: &content Content
        label: *content
        heading: *content
  account:
    comments: *comments
  activerecord:
    attributes:
      comment:
        content: *content

11. Actually Use Delegated Types?

So everything should now be working as expected, and here's the crazy thing: We haven't even used the delegated types feature yet. That was part of the beauty of delegated types when it was released in Rails 6.1: It was really just a formalization of an approach that folks had already been doing in Rails for years.



To now incorporate delegated types as put forward in the original documentation, we want to remove this line in app/models/entry.rb:

belongs_to :entryable, polymorphic: true

And replace it with:

delegated_type :entryable, types: %w[ Message Comment ]

We also want to follow the other steps seen there, such as defining an Entryable concern in app/models/concerns/entryable.rb, like so:

module Entryable
  extend ActiveSupport::Concern

  included do
    has_one :entry, as: :entryable, touch: true
  end
end

And including the Entryable concern in both app/models/message.rb and app/models/comment.rb like so:

include Entryable

Conclusion

That's it! You're done! As mentioned at the beginning, this is only one of the ways to approach building views and controllers around your models with delegated types, but it's a common one, and for the situations where it is the right fit, it requires a lot less code and is a lot more DRY than scaffolding views and controllers around each individual delegated type class.