Search code examples
ruby-on-railsactiverecordnested-forms

Rails: Need help understanding a join table, nested forms, associations


I'm a student learning Rails, and my project is to make a Recipes app. My recipes app has models Recipe, Ingredient, and other things, but the important thing is that an Ingredient must be the only ingredient of it's kind in the table. A row would be just an id and name. There can be only one "rice" in the table, so any Recipe that uses rice as an ingredient, uses that same row. That way (I guess) I can filter Recipes by Ingredients.

I have to use a nested form when creating/editing a Recipe, so the user can fill out the recipe info and ingredients all in one screen, including adding new Ingredients, and choosing ingredients from a drop list to select. In edit, the user can also delete (disassociate) and Ingredient from a Recipe.

I understand that I will need a join table (recipes_ingredients?? if so, what is the model called, RecipesIngredient?

I don't really understand how this nested form will work. I am to create a fields_for for all Ingredients? How do disassociate and create?

Maybe someone could point me in the right direction where I can read about this. I've been racking my brain for a long time, even starting the project over twice. I'm getting frustrated but I feel like I'm so close to understanding it.

I also tried using simple_form and cocoon, but I feel like that just confused me even more.

Any light shed on this subject would be amazing. Thank you.


Solution

  • This is a fairly typical join table setup:

    enter image description here

    Here we have a normalized table named ingredients which serves as the master record for an ingredient and recipe_ingredients which joins with the recipe table.

    In Rails we will set this up as a has_many through: association:

    class Recipe < ApplicationRecord
      has_many :recipe_ingredients
      has_many :ingredients, through: :recipe_ingredients
    end
    
    class Ingredient < ApplicationRecord
      has_many :recipe_ingredients
      has_many :recipes, through: :recipe_ingredients
    end
    
    class RecipeIngredient < ApplicationRecord
      belongs_to :recipe
      belongs_to :ingredient
    end
    

    The reason you want to use has_many through: and not has_and_belongs_to_many is that the later is "headless" as there is no model. Which might seem like a good idea until you realize that the is no way to access additional columns (like for example the quantity).

    When naming join tables for has_many through: you should follow the scheme of [thing in singular]_[thing in plural] due to the way that Rails resolves class names from table names. Using recipes_ingredients for example would result in a missing constant error as Rails would attempt to load Recipes::Ingredient. The join model itself should be named SingularSingular.

    You can of course also name join tables whatever fits the domain.

    To add the nested rows you would use nested attributes and fields_for :recipe_ingredients:

    <%= form_for(@recipe) do |f| %>
      <div class="field">
        <%= f.label :name %>
        <%= f.text_field :name %>
      </div>
    
      <fieldset>
        <legend>Ingredients</legend>
        <%= fields_for :recipe_ingredients do |ri| %>
        <div class="nested_fields">
           <div class="field">
             <%= ri.label :ingredient %>
             <%= ri.collection_select(:ingredient_id, Ingredient.all, :id, :name) %>
           </div>
           <div class="field">
             <%= ri.number_field :quantity %>
           </div>
        </div>
        <% end %>
      </fieldset>
    <% end %>
    

    However nested fields are in many ways a cludge to allow the creation / modification of several models at once. The UX and application flow of moshing everything together is hardly ideal.

    To provide a good UX its probably a better idea to apply incremental saves (the user saves the recipe before adding ingredients in the background with AJAX) and add the ingredients through a series of ajax POST requests to /recipies/:recipe_id/ingredients. But this is the subject of entire tutorial on its own and should most likely be revisited after you understand the basics.

    See: