Rails provides some help for building a complex HTML form that can edit multiple objects/database records at the same time. However, it is not everything needed for a form that allows adding and removing fields “on the fly” which is noted in the guide:
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. When generating new sets of fields you must ensure the key of the associated array is unique - the current JavaScript date (milliseconds since the epoch) is a common choice.
I did this today and wanted to note the approach, which is far from perfect but good enough for the little Rails app I was working on. The app has a model Recipe
with many dependent Step
records. I just wanted my recipe form to allow adding and removing recipe steps one at a time. I had to consider that steps of a recipe have an order to them too.
To illustrate what I mean, this is what the form first looks like when creating a new recipe (it is not completely polished yet):
Clicking the “New step” adds fields to the form:
And clicking “New step” again does the same, but removes the button to remove a step except for the last step:
Clicking the remove step button (the garbage can) removes the last step and makes the remove button appear on the step which becomes the last step next.
From a user perspective, the form works exactly the same for editing a recipe:
The recipes#create
action that handles the POST creating a new recipe with the dependent steps is this:
The action is very simple and only works because the model is configured with accepts_nested_attributes_for
:
The controller also needs to allow the nested params:
I wrote one request spec for the action that also could be useful for seeing the structure of the params that is expected for this to work:
The controller action and this spec were set up before working out how to add and remove fields on the fly in the form itself. At that point, I was seeing how far I could get with the fields_for
form helper that creates the form elements for dependent objects that submit the params with the structure expected by Rails. But it doesn’t work when the number of form elements is not fixed.
My approach was a combination of Turbo frames and some Stimulus controllers. Adding a new step makes a Turbo frame request:
I chose to use the normal steps#new
action for this which renders this:
At the end is where the “New step” link is added in after the new form elements, which will make a Turbo frame request to the same endpoint. The data-controller="new-step-form"
attaches a controller to that list item element that contains the form elements. Stimulus monitors the DOM for data-controller
elements so inserting this HTML from a turbo frame is not a problem. In this case Stimulus will look for a matching controller in the file new_step_form_controller.js
. The data-action="click->new-step-form#removeStep"
on the <button>
specifies that clicking that button will invoke the removeStep()
action of that controller. The controller will be able to target elements that have the attributes like data-new-step-form-target="numberInput"
. The controller sets up the attributes on those form element targets dynamically by interrogating how many step form elements are already on the page (To me this seems necessary to do. The attribute values need that information about their order, and it can’t be tracked on the server without also saving/deleting steps in the database when adding/removing form elements on the page. But that introduces its own complexity and does not fit the design of the controller action and model with accepts_nested_attributes_for
):
The connect()
lifecycle method executes when the controller connects to the DOM. The values of the attributes are set there so that the HTML is the same as what would be created by fields_for
. The removeStep()
action simply removes the element that the controller is attached to with this.element.remove()
and then calls the method that ensures the form is left with only the last step having a button to remove it.
Going back to the _form.html.erb
, before the new_step
turbo frame element, this is how the step form elements are created for steps that are already persisted in the database when editing an existing recipe:
It just iterates through the steps and creates the form elements for them in a different way, that is also pretty crude. A different Stimulus controller is used (data-controller="edit-step-form"
) and honestly it is just the first Stimulus controller but without the connect()
method implemented. Removing the code duplication there is an obvious way the implementation could be improved and I’ll refactor that. That duplication is obvious anyway, more concerning is information about the structure of the HTML steps form elements is not contained to a single place. It’s in recipes/_form.html.erb
for creating the form elements for steps in the database. For new steps, that information is in both the steps/new.html.erb
turbo frame and the Stimulus controller that sets the attributes on the form elements. Rearranging things to get that information more encapsulated would be good too.
The final thing to note is recipes#update
:
This could be improved too, at the very least with a transaction but it’s good enough.
Summary
This is just one way to do this. The implementation is crude in a few places with some clear potential for refactoring but it’s good enough for now to note the general idea while it’s in my head. New form elements are added with a turbo frame that connects to a Stimulus controller that interrogates the DOM to set the correct attributes on those form elements, since those are dependent on how many form elements there are already, which the server doesn’t know. Form elements for steps that have already been saved to the database are rendered in the template normally. Regardless of whether the form elements are rendered as part of a new step turbo frame or the initial form of the edit page, they are created just as they would be by fields_for
so that the controller actions can take advantage of accepts_nested_attributes_for
.