Assuming I have a recipe table that has a field for name. The form would look something like this:
<%= form_with(model: recipe, local: true) do |form| %>
<%= form.text_field :name %>
<%= form.submit %>
<% end %>
but if recipe is related to ingredients (which contains a name fields) through an intermediate table with the amount of the ingredient. how should i do the form to create a recipe, choose the ingredient and enter the amount of the ingredient. And also have the option to generate more fields if the recipe has more than one ingredient. All that in one form. Something like that:
<%= form_with(model: recipe, local: true) do |form| %>
<%= form.text_field :name %>
(select field to choose an ingredient)
(field for recipe_ingredient to ingress the amount of the ingredient)
(button to generate more fields for other ingredients)
<%= form.submit %>
<% end %>
rails version: 5.2.2
This is one of the harder things to do in rails because it requires building the form on the server and then editing the form on the browser with js when the user adds or removes fields. I'll try to show the bare minimum working example.
Lets first build the form on the server with fields_for which lets you add nested fields for a model that accepts_nested_fields_for
one of its relationships. In your case you will need to nest your form twice (first for the Recipe
's Dose
s and second for each Dose
's Ingredient
). Users won't realy see the Dose
model as it is only there as an intermediate table.
Let's say you've set up your app like this:
rails g scaffold Ingredient name:string
rails g scaffold Recipe name:string
rails g scaffold Dose ingredient:references recipe:references amount:decimal
Then add this to the Recipe
model:
has_many :doses
has_many :ingredients, through: :doses
accepts_nested_attributes_for :doses, allow_destroy: true
And this to the Dose
model:
accepts_nested_attributes_for :ingredient
Now edit the app/views/recipes/_form.html.erb
file and add the fields for Dose
<%= form.fields_for :doses do |dose_form| %>
<div class="field">
<%= dose_form.label :_destroy %>
<%= dose_form.check_box :_destroy %>
</div>
<div class="field">
<%= dose_form.label :ingredient_id %>
<%= dose_form.select :ingredient_id, @ingredients %>
</div>
<div class="field">
<%= dose_form.label :amount %>
<%= dose_form.number_field :amount %>
</div>
<% end %>
This won't do very much since fields_for will only generate the code inside its block if the relationship is populated. So let's populate the recipe
's doses
relationship in the new
and edit
actions of the app/controllers/recipes_controller.rb
file. While we're there let's add all the ingredients to our @ingredients
variable, and permit our nested attributes to the strong_parameters permitted
hash.
def new
@recipe = Recipe.new
@ingredients = Ingredient.all.pluck(:name, :id)
1.times{ @recipe.doses.build }
end
def edit
@ingredients = Ingredient.all.pluck(:name, :id)
end
...
def recipe_params
params.require(:recipe).permit(:name, doses_attributes: [:id, :ingredient_id, :amount, :_destroy])
end
You can build as many doses as you want, and once we setup the js part we can 'build' them on the front end. 1 is fine for now, just to show our dose fields.
Run the migrations and start the server and create a few ingredients then you will see them in the drop downs when creating a new recipe
Now you have a working nested field solution, but we have to build the doses in the backend and send the rendered form to the browser with a set number of built doses. Let's add some js to allow users to create and destroy nested fields on the fly.
Destroying fields is easy, since we have it all setup already. We just need to hide the field if the _destroy
checkbox is on. To do that let's install Stimulus
bundle exec rails webpacker:install:stimulus
And let's create a new stimulus controller in app/javascript/controllers/fields_for_controller.js
import {Controller} from "stimulus"
export default class extends Controller {
static targets = ["fields"]
hide(e){
e.target.closest("[data-target='fields-for.fields']").style = "display: none;"
}
}
And update our app/views/recipes/_form.html.erb
to use the controller:
<div data-controller="fields-for">
<%= form.fields_for :doses do |dose_form| %>
<div data-target="fields-for.fields">
<div class="field">
<%= dose_form.label :_destroy %>
<%= dose_form.check_box :_destroy, {data: {action: "fields-for#hide"}} %>
</div>
<div class="field">
<%= dose_form.label :ingredient_id %>
<%= dose_form.select :ingredient_id, @ingredients %>
</div>
<div class="field">
<%= dose_form.label :amount %>
<%= dose_form.number_field :amount %>
</div>
</div>
<% end %>
</div>
Great, now we hide the field when the user clicks the checkbox, and the backend will destroy the dose because the checkbox is checked.
Now let's look at the html nested_fields
generates to get some ideas of how we might let users add and remove them:
<div data-target="fields-for.fields">
<div>
<label for="recipe_doses_attributes_0__destroy">Destroy</label>
<input name="recipe[doses_attributes][0][_destroy]" type="hidden" value="0" /><input data-action="fields-for#hide" type="checkbox" value="1" name="recipe[doses_attributes][0][_destroy]" id="recipe_doses_attributes_0__destroy" />
</div>
<div class="field">
<label for="recipe_doses_attributes_0_ingredient_id">Ingredient</label>
<select name="recipe[doses_attributes][0][ingredient_id]" id="recipe_doses_attributes_0_ingredient_id"><option value="1">first ingredient</option>
<option selected="selected" value="2">second ingredient</option>
<option value="3">second ingredient</option></select>
</div>
<div class="field">
<label for="recipe_doses_attributes_0_amount">Amount</label>
<input type="number" value="2.0" name="recipe[doses_attributes][0][amount]" id="recipe_doses_attributes_0_amount" />
</div>
</div>
<input type="hidden" value="3" name="recipe[doses_attributes][0][id]" id="recipe_doses_attributes_0_id" />
The interesting bit is recipe[doses_attributes][0][ingredient_id]
specifically the [0]
it turns out fields_for
assigns an incremental child_index
to each of the built doses
. The backend uses that child_index
to know which children to delete or which attributes to update on which child.
So now the answer is clear, we just need to insert the same <div>
fields_for
created and set the child_index
of this new inserted <div>
to a higher value than the highest value previously in the form. Remember this is only an index
, not an id
, which means we can set it to a very big number since Rails will only use it to keep the nested fields attributes in the same group and actually assign id
s when saving the records.
So now we have to make two choices:
fields_for
<div>
fromFor the first choice the usual answer is to just get the current time and use that as child_index
For the second one the usual way is to move the html block into its own partial in app/views/doses/_fields.html.erb
then render that block twice inside the form in app/bies/recipes/_form.htm.erb
. Once inside the form.fields_for
loop. And a second time inside the data attribute of a button
where we create a new form just to generate one field_for
:
<div data-controller="fields-for">
<%= form.fields_for :doses do |dose_form| %>
<%= render "doses/fields", dose_form: dose_form %>
<% end %>
<%= button_tag("Add Dose", {data: { action: "fields-for#add", fields: form.fields_for(:doses, Dose.new, child_index:"new_field"){|dose_form| render("doses/fields", dose_form: dose_form)}}}) %>
</div>
Then use js to get the partial from the button's data tag update the child_index
and insert the updated html into the form. Since the button already has data-action='fields-for#add'
we just need to add the add action to our app/javascript/controllers/fields_for_controller.js
add(e){
e.preventDefault()
e.target.insertAdjacentHTML('beforebegin', e.target.dataset.fields.replace(/new_field/g, new Date().getTime()))
}
Now we don't need to build the doses
beforehand. Using a gem for this is a lot simpler, but the advantage of this is you can set it up exactly as you need and it doesn't add any code to your app that isn't needed.
Also it just occurred to me that Portion
would have been a better name for Dose