【Ruby on Rails】 cocoon gemで動的に要素を追加・削除できるフォームを作る
今回はRecipe
とIngredient
というモデルがあり、その中間テーブルとしてRecipeItem
というモデルがあります。
RecipeItemにはamount
という属性があり、Recipeのフォーム画面において、関連するIngredientをセレクトボックスから選び、その量(amount)も登録できるようにします。
動的に関連する要素(今回はIngredient)を追加していけるフォームを作成したいので、cocoon
というgemを導入することにします。
まず画像から
レシピの詳細画面はこんなです。
新規作成画面。わかりにくいですが、セレクトボックスで「Bacon」を選択し、その下の「Amount」に100を入力しています。
さらに「add Ingredient」というリンクをクリックすると、フォームが追加されます。
そしてレシピを作成すると「Bacon」と「Chicken fat」が登録されています。
Install cocoon
Gemfile
gem 'cocoon'
これだけでなく、application.js
に以下を記述します。
//= require cocoon
Recipe
app/models/recipe.rb
# Table name: recipes # # id :integer not null, primary key # title :string not null # instructions :string not null class Recipe < ActiveRecord::Base has_many :ingredients, through: :recipe_items has_many :recipe_items, dependent: :destroy accepts_nested_attributes_for :recipe_items, allow_destroy: true end
instructions
を複数形にしてしまいましたが、まあいいやw
フォーム内でrecipe_items
をネストできるようにaccepts_nested_attributes_for :recipe_items
を記述します。動的に削除したいので、さらにallow_destroy: true
も。
ちなみにこの記事内ではバリデーションなどの記述は省いています。
recipes_controller.rb
# GET /recipes/new def new @recipe = Recipe.new @recipe.recipe_items.build end . . . def recipe_params params.require(:recipe).permit(:title, :instructions, recipe_items_attributes: [:id, :amount, :recipe_id, :ingredient_id, :_destroy, ingredient_attributes:[:name, :nutrient]]) end
ホワイトリストがごちゃごちゃしていますが・・・。まずネストした要素として
recipe_items_attributes: [:id, :amount, :recipe_id, :ingredient_id, :_destroy]
があります。注意点としては、:id
と:_destroy
は必須ということです。
To destroy nested models, rails uses a virtual attribute called destroy. When destroy is set, the nested model will be deleted. If the record is persisted, rails performs id field lookup to destroy the real record, so if id wasn't specified, it will treat current set of parameters like a parameters for a new record.
When using strong parameters (default in rails 4), you need to explicitly add both :id and :_destroy to the list of permitted parameters.
https://github.com/nathanvda/cocoon#strong-parameters-gotcha
さらにその中の一要素としてingredient_attributes:[:name, :nutrient]
を追加しています。
つまり
recipe_items_attributes: [:id, :amount, :recipe_id, :ingredient_id, :_destroy]
↓
recipe_items_attributes: [:id, :amount, :recipe_id, :ingredient_id, :_destroy, ingredient_attributes:[:name, :nutrient]]
おわかりいただけただろうか・・・。
RecipeItem
# Table name: recipe_items # # id :integer not null, primary key # recipe_id :integer # ingredient_id :integer # amount :float default(0.0), not null class RecipeItem < ActiveRecord::Base belongs_to :recipe belongs_to :ingredient end
中間テーブル。まあここはなんてことはないです。
Ingredient
# Table name: ingredients # # id :integer not null, primary key # name :string # nutrient :integer class Ingredient < ActiveRecord::Base has_many :recipe_items, dependent: :destroy has_many :recipes, through: :recipe_items enum nutrient: { saturated_fat: 1, trans_fat: 2, sodium: 3, sugar: 4 } end
enumでnutrient(栄養素)を4つ作っています。参考にしたのはこちらのサイト
(記事書いてて思ったのですが、別にnutrient
は無くてもいいかもです)
レシピのform作成
app/views/recipes/_form.html.erb
<%= form_for(@recipe) do |f| %> <% if @recipe.errors.any? %> <div id="error_explanation"> <h2><%= pluralize(@recipe.errors.count, "error") %> prohibited this recipe from being saved:</h2> <ul> <% @recipe.errors.full_messages.each do |message| %> <li><%= message %></li> <% end %> </ul> </div> <% end %> <div class="field"> <%= f.label :title %><br> <%= f.text_field :title, required: true %> </div> <div class="field"> <%= f.label :instructions %><br> <%= f.text_field :instructions, required: true %> </div> <div id="ingredients" class="form-group"> <label>Ingredients:</label> <%= f.fields_for :recipe_items do |builder| %> <%= render 'recipe_item_fields', f: builder %> <div id="links"> <%= link_to_add_association "add Ingredient", f, :recipe_items %><br/> </div> <% end %> </div> <div class="actions"> <%= f.submit %> </div> <% end %>
レシピ用の2つのtext_field
の下に、材料用のフィールドを作成、そこでさらにrecipe_items用のパーシャルをレンダリングしていると。
パーシャル名は基本_{model}_fields.html.erb
です。モデル名なので単数形じゃないといけないです(ちょっとハマった)
If no explicit partial name is given, cocoon looks for a file named <association-object_singular>fields. To override the default partial use the :partial option.
https://github.com/nathanvda/cocoon#partial-1
<%= link_to_add_association "add Ingredient", f, :recipe_items %>
で関連追加用のリンクを生成しています。
パーシャル作成
app/views/recipes/_recipe_item_filelds.html.erb
<div class="nested-fields form-inline"> <%= f.collection_select(:ingredient_id, Ingredient.all, :id, :name) %> <div class="form-group"> <%= f.label :amount %> <%= f.number_field :amount, step: '0.1', min: '0.0', required: true, :class => 'form-control' %> </div> <%= link_to_remove_association "remove ingredient", f %> </div>
まずパーシャル内では.nested-fields
クラスを付加したコンテナが必要です。このクラスを見てJSで削除してるみたい。
For the JavaScript to behave correctly, the partial should start with a container (e.g. div) of class .nested-fields, or a class of your choice which you can define in the link_to_remove_association method.
https://github.com/nathanvda/cocoon#partial-1
collection_select
を使用して材料をセレクトボックスから選択します。
<%= link_to_remove_association "remove ingredient", f %>
で、このパーシャル部分を削除できるリンクを生成しています。
あとはnumber_field
においてstep: '0.1'
の指定くらいですかね。:amount
がfloat
なので。
その他参考記事
Nested Attributes and a has_many :through Relationship. – mostlybadfly – stuff