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:
- Centralize views and controllers around the Abstract Parent (e.g.
Account::EntriesController
). - 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:
- 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. - Controller permissions can be enforced the same way they always are, by checking the relationship between the Abstract Parent (e.g.
Entry
) andTeam
. All permissions are defined inapp/models/ability.rb
forEntry
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.)
Entry
2. Super Scaffolding for 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>
new.html.erb
4. Add Our First Step to 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:
- Ask the user what type of
Entry
they're creating. - 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
:
- Add the
if @entry.entryable_type
branch logic, maintaining the existing reference to_form.html.erb
. - Add
@
to theentry
references throughout.@entry
is an instance variable in this view, not passed in as a local. - Update the form submission
url
andmethod
as seen above. - Remove the Super Scaffolding hooks. Any additional fields that we add to
Entry
would be on the actual_form.html.erb
, not this step. - 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
entry.rb
6. Add Appropriate Validations in 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!
entry.rb
and entries_controller.rb
7. Accept Nested Attributes in 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.)
@entry.entryable
in entries_controller.rb
8. Populate 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
_form.html.erb
9. Add the Concrete Children Fields to the Second Step in 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.
show.html.erb
10. Add Attributes of the Concrete Children to 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.
Really loving the PR for Rails 6.1's Delegated Types. From the application developer level, very little of it feels "new". Instead, the experience reads very similar to what many of us were already doing with the existing tools, but even smoother! https://t.co/6UkxXNCvaa
— Andrew Culver (@andrewculver) December 13, 2020
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.