I found myself struggling when it came to refactor the following (working) code. Since can't be found a lot of documentation about Rails 'accepts_nested_attributes_for' applied to a many-to-many relationship (furthermore, with ancillary data), I figured out it would be interesting to challenge this brave community about it! :-)
The app logic is quite simple, we have a recipe model:
class Recipe < ActiveRecord::Base
has_many :quantities, dependent: :destroy
has_many :ingredients, through: :quantities
accepts_nested_attributes_for :quantities, :reject_if => lambda { |dq| dq[:ingredient_id].blank? || dq[:qty].blank? }, :allow_destroy => true
attr_accessible :name, :description, :kind
end
Which, of course, has many ingredients:
class Ingredient < ActiveRecord::Base
has_many :quantities, dependent: :destroy
has_many :recipes, through: :quantities
attr_accessible :name, :availability, :unit
end
Through quantities:
class Quantity < ActiveRecord::Base
belongs_to :recipe
belongs_to :ingredient
attr_accessible :recipe_id, :ingredient_id, :qty
# There can be only one of each ingredient per recipe
validates_uniqueness_of :ingredient_id, scope: :recipe_id
validates_numericality_of :qty, greater_than: 0
end
Please note: every ingredient has an 'in stock' availability (and a unit of measurement for it), while every recipe needs a given 'quantity.qty*' of some ingredients in order to be prepared.
* Quantity.where(ingredient_id: ingredient.id, recipe_id: recipe.id).qty
By the way, do you know a more concise way to get some ingredient quantity.qty for some recipe?
Back to the main context, it is mainly about the following views. The difficult part regards the fact that the fields are dynamically displayed or removed through jQuery, as in this Railscast: http://railscasts.com/episodes/197-nested-model-form-part-2.
So the next one displays the form to create and edit a recipe:
<%= simple_form_for(@recipe) do |f| %>
<%= render 'shared/error_messages', object: @recipe %>
<fieldset>
<legend>Ricetta</legend>
<%= f.input :name %>
<%= f.input :description %>
<%= f.input :kind, collection: Recipe::KINDS, prompt: "Scegli..." %>
<%= f.input :cooked %>
<%= render 'quantities_fields' %>
<div class="form-actions">
<%= f.button :submit, "Confirm" %>
</div>
</fieldset>
<% end %>
Where the quantities_fields partial is the following (now it comes the mayhem):
<fieldset class="nested">
<legend>Ingredienti</legend>
<ol id='ingredients'>
<%= render :partial => 'dummy_hidden_fields' %>
<% for dq in @recipe.quantities %>
<li class="fields">
<%= select_tag 'recipe[quantities_attributes][][ingredient_id]',
options_from_collection_for_select(ingredients_for_select, 'id', 'name_unit', dq.ingredient_id) -%>
<%= text_field_tag 'recipe[quantities_attributes][][qty]', ingredient_qty_for_select(dq), size: 3, placeholder: "Q.tà" %>
<!-- the id must be POST in order to trigger update instead of create when appropriate! -->
<%= hidden_field_tag 'recipe[quantities_attributes][][id]', dq.id %>
<!-- the following must be changed to '1' via js in order to destroy this quantity -->
<%= hidden_field_tag 'recipe[quantities_attributes][][_destroy]', 0 %>
<%= link_to_function "elimina", "remove_ingredient(this)", :class => 'btn btn-mini' %>
</li>
<% end %>
</ol>
<%= link_to_function "Add ingredient", "add_ingredient()" %>
</fieldset>
Yes, I wasn't able to use 'fields_for' because I couldn't figure out a proper way.
The 'dummy_hidden_field" partial is used to create some initially hidden fields to be displayed through jQuery if a new ingredient is needed:
<li class="fields hidden">
<%= select_tag 'recipe[quantities_attributes][][ingredient_id]', options_from_collection_for_select(ingredients_for_select, 'id', 'name_unit') -%>
<%= text_field_tag 'recipe[quantities_attributes][][qty]', nil, size: 3, placeholder: "Q.tà" %>
<%= hidden_field_tag 'recipe[quantities_attributes][][_destroy]', 0 %>
<%= link_to_function "elimina", "remove_ingredient(this)", :class => 'btn btn-mini' %>
</li>
And for last the above-mentioned jQuery functions:
function remove_ingredient(link) {
$(link).prev("input[type=hidden]").val("1");
$(link).closest(".fields").hide();
}
function add_ingredient() {
var ol = $('ol#ingredients');
ol.find('li:first').clone(true).appendTo(ol).removeClass('hidden');
}
For any further information please don't hesitate to ask!