ruby-on-rails
Development

[Rails] has_many through な関連をチェックボックスで操作するときに苦労した話

WordPressのカテゴリー選択UIみたいのをつくりたい

Railsで多対多の関係を定義する方法として has_and_belongs_to_many ではなく has_many through を使うようになって久しいですが、accepts_nested_attributes_forfields_forを使って関連データを親モデルのフォームから操作するのは未だに慣れません。

ググって調べたところによると、RailsCastsで紹介されている方法が紹介されている方法が一般的なんですかね。

#196 Nested Model Form (revised)

Github ryanb/nested_form

この方法だとテキストフィールドをつけたり外したりするのが便利なんだけど、既に用意されているリストから選ぶUIの場合、つまりWordPressのカテゴリー選択みたいなのを作るのはキツイのかなと。

スクリーンショット 2014-04-08 9.40.56
↑こんなやつ

[問題]fields_forは関連データを持っていないとブロックを実行しない

そもそも fields_for はオブジェクトが関連データを持っていないとフォームを作成してくれませんよね?

このようなモデルの関係があった場合に

class Article < ActiveRecord::Base
  has_many :categorizations
  has_many :categories, through: :categorizations

  accepts_nested_attributes_for :categorizations, allow_destroy: true
end

class Categorization < ActiveRecord::Base
  belongs_to :article
  belongs_to :category
end

class Category < ActiveRecord::Base
  has_many :categorizations
  has_many :articles, through: :categorizations
end

これだけじゃ fields_for の中は実行されず…

- @article = Article.new

= form_for @article do |f|
  = f.fields_for :categorizations do |cf|
    = cf.collection_select :category_id, Category.all, :id, :name

@article.categorizations.length == 0 だからブロックが実行されないんですよね。
そこで nested_form というgemの出番で、f.link_to_addとかcf.link_to_removeというヘルパーが追加されるので超便利。というのはわかるんだけどこのUIじゃないんですよ作りたいのは。

name属性手書きとか、JavaScriptで対応するの嫌なんです

上記の方法でだめなら fields_for 捨ててinput要素手書きしますか?

- Category.all.each do |cat|
  %label
    %input{ type: 'checkbox', name: 'article[categorizations_attributes][][category_id]', value: cat.id }
    = cat.name

自分で書いててそんなに悪くないかな…とちょっと思ってしまったんですが、やっぱりこんなのダメだと思う。てゆうかこれだと一度つくった関連消せないしね。

となるとチェックボックス外した時にJSで _destroy キーに true をセットする hidden 要素を生成するのか・・・!
いやいやこれも無いでしょ。処理があちこちに散らばるからね。viewをパッと見ただけじゃなんで動いてるのかわからないよ。

そうだ、Helper作ってやろう

となるとどうすりゃいいんだ!あーーー!とか思ってた時にこんな記事を見つけました。

Complex Rails Forms with Nested Attributes

全部読んだわけじゃないんですが、参考にしたのはControllerにもViewにも置きたくない複雑な処理をHelperに書いているところ。なんだ普通のコトじゃないかと思うかもしれませんが、いや僕もこれ書いてて普通だなって思いましたが見つけた時は結構目からウロコだったんですよね。

なので僕のViewコードはこうなりました

= form_for setup_categorizations(@article) do |f|
  %ul
    = f.fields_for :categorizations do |cf|
      %li
        %label
          = cf.hidden_field :id
          = cf.hidden_field :category_id
          = cf.check_box :apply, { checked: cf.object.persisted? }
          = cf.object.category.name

setup_categorizations でArticleモデルを渡して既に関連付けられていないcategoryだけをbuildしてセットしています

module ArticleFormHelper
  def setup_categorizations(article)
    Category.all.each do |cat|
      unless article.categorizations.select{ |c| c.category_id == cat.id }.any?
        article.categorizations.build({ category_id: cat.id })
      end
    end
    article
  end
end

categorizations_attributes として渡すパラメータに apply というキーの真偽値を仕込んであるのでこれを元に以下の処理をController側で行います

  1. applyキーが0でpersistedなデータなら_destroyフラグを立てる
  2. applyキーが1ならcategory_id付きで残しておく

この処理をRails4ならstrong parameter使いつつこんな感じにしておいて

class ArticlesController < ApplicationController
  include ArticleFormHelper

  def create
    @article = Article.new(article_params)
    ....
  end

  private
    def article_params
      _params = params.require(:article).permit(:title, :body, categorizations_attributes: [:id, :category_id, :apply])
      setup_categorizations_attributes(_params)
    end
end

先ほどのArticleFormHelperにsetup_categorizations_attributesメソッドを追加します

module ArticleFormHelper
  ....

  def setup_categorizations_attributes(params)
    # キーが数字の文字列のhashになっていたため配列にする "0" => {id: 1}
    attrs = params[:categorizations_attributes].dup.map{ |k, v| v }
    params[:categorizations_attributes] = attrs.inject([]) { |res, attr|
      if attr[:apply].to_i == 0
        res.push({ id: attr[:id], _destroy: 1 }) if attr[:id].persent?
      else
        res.push({ id: attr[:id], category_id: attr[:category_id] })
      end
      res
    }
    params
  end

自分的にはこれでスッキリしたんですが、もっといい方法あったら是非教えて欲しいです。
あとモデルの命名どうなの?とかもあったらコメントしてもらえると嬉しいです!

標準