読者です 読者をやめる 読者になる 読者になる

みかづきメモ

学習したことのメモとか、日記とか、備忘録。

cocoon を使って、ネストしたフォームを作る

Ruby Ruby on Rails

Ruby の仕事しか降ってこない私です。
C# のお仕事待っています。

cocoon という gem を使って、ネストしたフォームを作る方法のメモです。


Rails にて、ネストしたフォームを作るための gem は nested_form が有名なようですが、
長期間メンテされていません。

ということで、同等の機能を持ち、メンテナンスもされている、 cocoon を使って、
多対多リレーションを持つモデルのフォームを作ってみようと思います。

いつもどおり、インストールから。

# @ Gemfile

gem 'cocoon'


JavaScript のコードが含まれているので、 application.js も変更しておきます。

// @ app/assets/javascripts/application.js

//= require cocoon


これで、準備ができたので、ネストしたフォームを作っていきます。

1対多 のフォーム

Entity 同士のリレーションが、1対多の場合のフォームを作っていきます。
関係性はこんな感じ。

+------+ 1  * +---------+
| Item +------+ Website |
+------+      +---------+

登録するアイテムを販売しているサイトを、複数箇所登録できる…といった感じです。
では、モデルのコード。

# @ app/models/item.rb

class Item < ActiveRecord::Base
  ALLOWED_PARAMS = [:name, :description, :price]

  has_many :websites, dependent: :destroy
  accepts_nested_attributes_for :websites, allow_destroy: true
end
# @ app/models/website.rb

class Website < ActiveRecord::Base
  NESTED_ALLOWED_PARAMS = [:id, :_destroy, :url]
  
  belongs_to :item
end

そして、コントローラー。

# @ app/controllers/items_controller.rb

class ItemsController < ApplicationController
  # いろいろ省略
  def new
    @item = Item.new
  end
  
  def create
    @item = Item.new(item_params)
    # 保存など
  end
  
  # ~
  private
  def item_params
    params.require(:item).permit(
      Item::ALLOWED_PARAMS,
      websites_attributes: Website::NESTED_ALLOWED_PARAMS
    )
  end
end

コントローラーの permit の部分で、ネストしたモデルも指定しておきます。
この際、 :id 及び、 :_destroy も含めておく必要があります。

最後に、ビューを作ります。

-# @ app/views/items/_form.html.haml

= form_for @item do |f|
  .form
    = f.label :name
    = f.text_field :name

  .form
    = f.label :description
    = f.text_area :description
    
  .form
    = f.label :price
    = f.text_field :price
    
  %h4 Websites
  #websites
    = f.fields_for :websites do |website|
      = render 'website_fields', f: website
    .links
      = link_to_add_association "Website を追加", f, :websites

この時、指定する render は、 Model名_fields でないといけないようです。
(別のものを指定したら、エラーが出ました。)

-# @ app/views/items/_website_fields.html.haml
.nested-fields
  .form
    = f.label :url
    = f.text_field :url, placeholder: "http://"
    
  = link_to_remove_association "Website を削除", f

.nested-fields, .links は、必須項目です。
これより下の部分が、追加する際のテンプレートとして使用されます。

これで、完成です。


多対多 のフォーム

次は、 Entity 同士の関係が、多対多の場合のフォームを作ります。
関係性はこんな感じかな。

+------+ *  * +----------+
| Item +------+ Category |
+------+      +----------+

コードとしては、一応間に中間テーブル(ItemCategory)があります。
ということで、コードをいじっていきます。

まずは該当のモデルから。

# @ app/models/category.rb
class Category < ActiveRecord::Base
  ALLOWED_PARAMS = [:id, :_destroy, :name]
  
  has_many :item_categoris
  has_many :items, through: :item_categories
end


次は中間テーブル。
accepts_nested_attributes_forcategory を変更できるようにしているのがポイント。

# @ app/models/item_category.rb
class ItemCategory < ActiveRecord::Base
  NESTED_ALLOWED_PARAMS = [
    :id, :_destroy, :item_id, :category_id,
    :category_attributes: Category::NESTED_PARAMS
  ]
  
  belongs_to :cateroy
  belongs_to :item

  accepts_nested_attributes_for :category, reject_if: :all_blank
end


次は、先ほど1対多で使ったコードに対して、以下のとおり変更を加えます。

 # @ app/models/item.rb
 class Item < ActiveRecord::Base
   ALLOWED_PARAMS = [:name, :description, :price]

   has_many :websites, dependent: :destroy
+  has_many :item_categories
+  has_many :categories, through: :item_categories
  
   accepts_nested_attributes_for :websites, allow_destroy: true
+  accepts_nested_attributes_for :item_categories, allow_destroy: true
+  accepts_nested_attributes_for :categories
 end
 # @ app/controllers/items_controller.rb

 class ItemsController < ApplicationController
   # いろいろ省略
   def new
     @item = Item.new
   end
  
   def create
     @item = Item.new(item_params)
     # 保存など
   end
  
   # ~
   private
   def item_params
     params.require(:item).permit(
       Item::ALLOWED_PARAMS,
-      websites_attributes: Website::NESTED_ALLOWED_PARAMS
+      websites_attributes: Website::NESTED_ALLOWED_PARAMS,
+      item_categories_attributes: ItemCategory::NESTED_ALLOWED_PARAMS
     )
   end
 end


あとは、 View を用意していきます。

-# @ app/views/items/_category_fields.html.haml
.nested-fields
  .form
    = f.label :name
    = f.text_field :name, placeholder: "Jewelry"

  = link_to_remove_association "Category を削除", f
-# @ app/views/items/_category_item_fields.html.haml
.nested-fields.category-child
  .form
    = f.label :category_id
    = f.collection_select :category_id, Category.all, :id, :name

  = link_to_add_association "新しい Category を作成", f, :category
  %br
  = link_to_remove_association "Category を削除", f

_form にも。

 -# @ app/views/items/_form.html.haml

 = form_for @item do |f|

~ 略 ~
    
   %h4 Websites
   #websites
     = f.fields_for :websites do |website|
       = render 'website_fields', f: website
     .links
       = link_to_add_association "Website を追加", f, :websites

+  %h4 Categories
+  #categories
+    = f.fields_for :category_items do |category_item|
+      = render "category_item_fields", f: category_item
+    .links
+      = link_to_add_association "Category を追加", f, :category_items

最後に、 JavaScript で挙動を修正します。

// @ app/assets/javascripts/items.js
$(function() {
  $("#categories").on("cocoon:after-insert", function() {
    $(this).find(".category-child").bind("cocoon:after-insert", function() {
      $(this).remove();
    });
  });
});

これで完成です。
アクセスすれば、ちゃんと動くはずです。


個数を制限する

公式 Wiki によると、個数制限もできるようなので、やってみます。
とりあえず、最低1つ、最大5つという制限をつけてみます。

ということで、 Wiki の通り、 JavaScript を書いていきます。
まぁ、読み込まれればどこでも良いんですけども、面倒なので、フォームの HAML に書きます。

-# @ app/views/layout/application.html.haml

!!!
%html
 -# 省略
 %body
   - # 省略
   = yield :javascript

とし、

-# @ app/views/items/_form.html.haml

-# 省略
- content_for :javascript do
  :javascript
    $(function() {
      $('#websites a.remove_fields').hide();

      $('#websites').on('cocoon:after-insert', function() {
        check_content();
      });
      
      $('#websites').on('cocoon:after-remove', function() {
        check_content();
      });
      
      function check_content() {
        if($('#websites .nested-fields').length == 5) {
          $('#websites a.add_fields').hide();
        } else {
          $('#websites a.add_fields').show();
        }
      }
    });

あと、最初から1つぶん表示させておくために、 Controller にも変更を加えます。

# @ app/controllers/items_controller.rb
class ...
  def new
    @item = Item.new
    @item.websites.build
  end
end

こうすることで、最初に決めた「最低1つ、最大5つ」の制限をつけることができます。

ということで、 cocoon でした。