ハイパーニートプログラマーへの道

頑張ったり頑張らなかったり

【Ruby on Rails】 cocoon gemで動的に要素を追加・削除できるフォームを作る

今回はRecipeIngredientというモデルがあり、その中間テーブルとしてRecipeItemというモデルがあります。
RecipeItemにはamountという属性があり、Recipeのフォーム画面において、関連するIngredientをセレクトボックスから選び、その量(amount)も登録できるようにします。
動的に関連する要素(今回はIngredient)を追加していけるフォームを作成したいので、cocoonというgemを導入することにします。

まず画像から

レシピの詳細画面はこんなです。

f:id:noriyo_tcp:20160114155605p:plain

新規作成画面。わかりにくいですが、セレクトボックスで「Bacon」を選択し、その下の「Amount」に100を入力しています。

f:id:noriyo_tcp:20160114155616p:plain

さらに「add Ingredient」というリンクをクリックすると、フォームが追加されます。

f:id:noriyo_tcp:20160114155617p:plain

そしてレシピを作成すると「Bacon」と「Chicken fat」が登録されています。

f:id:noriyo_tcp:20160114155618p:plain

Install cocoon

github.com

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つ作っています。参考にしたのはこちらのサイト

Ingredient list

(記事書いてて思ったのですが、別に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'の指定くらいですかね。:amountfloatなので。

その他参考記事

Nested Attributes and a has_many :through Relationship. – mostlybadfly – stuff


githubリポジトリ作りましたー。

github.com