Dynamic Polymorphic Forms in Rails 7
It was time to move my Meal Planning app from AirTable to a custom application. AirTable’s pricing for the size of the database wasn’t awful, but I’d moved past the free account limits. And there were some features I wanted to add that were hard with the basic AirTable features.
It’s been a while, so let’s write a Rails app!
I exported CSVs from AirTable, ran bundle && rails new
and got to test-driving. Rails 7 and Resource scaffolding just worked as I expected. Recipes
and Restaurants
came together very quickly.
As soon as I tried to model a calendar of days, where days could have one or more restaurants and one or more recipes (a key feature of the current solution), things got complex.
I ran into three problems:
- Rails and forms with
has_many
associations - But the association is polymorphic
- But the form really needs to be dynamic in order to add/remove things from the association
Complex Forms
I had my Recipe
and Restaurant
models and full controllers. Now I wanted to add a Day
model that has a date as well has has_many :recipes
as a starting point.
Rails has gotten pretty mature when it comes to the fields_for
helper that basically gives you a nested form that includes your association. The Complex Forms section of the Action View Form Helpers Rails Guide talks about People with more than one address. This is great for explaining how form_with
and fields_for
work together.
To keep things simple, I only tried to add and edit one Recipe
on a Day
. It was working great. Now it was time to make the association polymorphic.
Polymorphic has-and-belongs-to-many
I needed to be able to add one recipe or one restaurant. I tried several different modelings of the associations to get a polymorphic has_many
to work and couldn’t get all the tests passing. Then I found this StackOverflow post that was. I needed a separate join table with some has_many through
’s’.
Of course! I needed something that looked more like:
Day <-> Meal (Course <-> [Recipe | Restaurant])
Which got me to these models:
class Day < ApplicationRecord
validates :date, uniqueness: true
validates :date, presence: true
has_many :meals, dependent: :destroy
end
class Meal < ApplicationRecord
belongs_to :day
belongs_to :course, polymorphic: true
end
class Recipe < ApplicationRecord
validates :name, presence: true
has_many :meals, as: :course
has_many :days, through: :meals
end
class Restaurant < ApplicationRecord
validates :name, presence: true
has_many :meals, as: :course
has_many :days, through: :meals
end
(Note: I’m not thrilled with the name of course
, but it’s close enough.)
Now with the form, I could put one Recipe
select and one Restaurant
select and be able to have one or the other. There were some bugs, sure. But I was creating, updating, and saving Day
s. Progress!
Next question - how do I get the form properly dynamic, adding as many recipes and restaurants as I wanted?
Dynamic Complex Polymorphic Forms
There’s a nice caveat at the Rails Guide for forms:
Rather than rendering multiple sets of fields ahead of time you may wish to add them only when a user clicks on an “Add new address” button. Rails does not provide any built-in support for this.
Whoops. I’m on my own.
To recap, I knew that what I wanted was for a Day
form to start with no Meals, but then be able to add or delete many Recipes
and Restaurants
and submit that form at the end to create or update the Day
.
Time to learn Turbo and Hotwire.
I found two great pages that helped immensely. First, was Alexis Chávez’s guide to Turbo & Hotwire. The concepts were clear - it’s a bit like old RJS, but more formal thanks to how Controllers have evolved. But how do I structure the form?
Back to StackOverflow, and a nicely detailed question Rails 7 Dynamic Nested Forms with hotwire/turbo frames. It models cocktails with ingredients and looked a lot like what I needed.
I had some patterns! Time for some code.
DaysController gets new methods
First, a couple of new routes:
resources :days do
get :new_meal, on: :collection, path: "new_meal/:course_type"
delete :destroy_meal, on: :collection, path: "destroy_meal/(:id)"
end
Then in the Day
form I added two Turbo links that call the #new_meal
method: one to add a Recipe
and one to add a Restaurant
.
.meals
...
= link_to new_meal_days_path("Recipe"), data: { turbo_method: :get } do
%i.fa-regular.fa-square-plus
= "Add Recipe"
= link_to new_meal_days_path("Restaurant"), data: { turbo_method: :get } do
%i.fa-regular.fa-square-plus
= "Add Restaurant"
Then in DaysController
:
def new_meal
helpers.fields model: Day.new do |f|
f.fields_for :meals,
Meal.new,
child_index:
Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) do |ff|
render turbo_stream:
turbo_stream.append("meals",
partial: "meal_fields",
locals: {f: ff, klass: params[:course_type] })
end
end
end
Copying from the “Cocktails” post, this method:
- Creates a form helper that will build the tags for a
Day
and then oneMeal
- Passes the
fields_for
form helper (ff
) to the view, along with thecourse_type
that came in from theGET
And then the meals_fields
view…
- div_id = "meal_#{f.index}"
%div{id: div_id}
= f.hidden_field :id
= f.hidden_field :course_type, value: course_type
.course_edits
= f.collection_select :course_id,
course_type.constantize.order(:name),
:id,
:name,
prompt: "Select a #{course_type}..."
= link_to destroy_meal_days_path(id: f.object.id, div_id: div_id),
data: { turbo_method: :delete } do
%i.fa-regular.fa-square-minus
This builds a div
with a unique ID (from the timestamp in the controller method) that renders:
- Hidden fields for the
Meal
id
andcourse_type
- A select with that type’s list (yay,
String#constantize
!) - A link to destroy this section of the form, if the user so chooses
And that link routes back to DaysController#destory_meal
def destroy_meal
Meal.find(params[:id]).destroy! if params[:id]
render turbo_stream: turbo_stream.remove(params[:div_id])
end
Which will…
- Find and destroy the
Meal
if this is aDay#edit
and so anid
is provided; but ignores it if not - Removes this
Meal
’sdiv
inside theDay
form
It’s Alive!
Phew! Thanks, Internet!