Search code examples
ruby-on-railsruby-on-rails-5

Rails Nesteds Forms with dynamic fields


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


Solution

  • 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 Doses 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 ids when saving the records.

    So now we have to make two choices:

    1. Where to get the incremental index from
    2. Where to get the fields_for <div> from

    For 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