WordPressのカテゴリー選択UIみたいのをつくりたい
Railsで多対多の関係を定義する方法として has_and_belongs_to_many
ではなく has_many through
を使うようになって久しいですが、accepts_nested_attributes_for
と fields_for
を使って関連データを親モデルのフォームから操作するのは未だに慣れません。
ググって調べたところによると、RailsCastsで紹介されている方法が紹介されている方法が一般的なんですかね。
#196 Nested Model Form (revised)
この方法だとテキストフィールドをつけたり外したりするのが便利なんだけど、既に用意されているリストから選ぶUIの場合、つまりWordPressのカテゴリー選択みたいなのを作るのはキツイのかなと。
[問題]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側で行います
- applyキーが0でpersistedなデータなら_destroyフラグを立てる
- 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
自分的にはこれでスッキリしたんですが、もっといい方法あったら是非教えて欲しいです。
あとモデルの命名どうなの?とかもあったらコメントしてもらえると嬉しいです!