27
28

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

[Rails]ransackで詳細検索フォームを作成したメモ書き

Posted at

#はじめに
メルカリの下記のような詳細検索フォームの作成記です。
特にまとめではなく、細かい気付きをつらつらと書いているものです。

同じような課題にぶつかった方の参考に少しでもなれば、と思います。

56b5aab1bc98bb048821fc50814e8eeb.gif

#完成したフォーム
▼並び替え
201418e4d94c98c62f249943c2713b93.gif

▼カテゴリ
f509c4b3dbb2abf3fad585bebb2963c0.gif

▼価格帯、チェックボックス
d9de65f9cc351577645bcb4aee008bf9.gif

▼クリアボタン
d6353bb4e2e151c601fe06ab613e79ae.gif

#詰まった箇所

  1. ransackのクエリ問題
  2. 検索結果の並び替えの実装
  3. セレクトボックスのビューでの表現
  4. チェックボックスのビューでの表現
  5. ancestryを使用する項目とransackとの関係
  6. JavaScriptで「すべて」ボタンの実装
  7. 検索条件のクリアボタンの実装

##前置き
ransackの説明や使い方はGithubや下記の記事を参考にしてください。

Github - ransack
[Rails]ransackを利用した色々な検索フォーム作成方法まとめ

##1. ransackのクエリ問題
###1-1. 課題:検索クエリ(@q)を各コントローラーに配置しておく必要がある
上図のように、ヘッダーに検索フォームを置いたので、ほとんどのコントローラーでbefore_actionでクエリ(@q)をセットしておく必要がある。

そうしないと下記のエラー(No Ransack::Search object was provided to search_form_for!)が発生してしまう。
スクリーンショット 2020-06-09 18.12.03.png

前述のQiitaでいうところのindexアクションの記述が必要

items_controller.rb
class ItemsController < ApplicationController
  def index
    @q = Item.ransack(params[:q])
    #ここで定義している@qがないと、search_form_forで上記エラーが発生する
    @items = @q.result(distinct: true)
  end

  def search
    @q = Item.search(search_params)
    @items = @q.result(distinct: true)
  end

  private
  def search_params
    params.require(:q).permit!
  end
end

最初は、各コントローラーでプライベートメソッドを定義して、before_actionしていた。

tops_controller.rb
class TopsController < ApplicationController
  skip_before_action :authenticate_user!
  before_action :set_item_search_query

  def index  
    @items = Item.including.limit(5).desc.trading_not
  end
  
  private
  def set_item_search_query
    @q = Student.ransack(params[:q])
    @items = @q.result(distinct: true)
  end

end

ただこの方法だと、検索フォームがある(=ヘッダーを表示する)ページを呼び出すコントローラー全てに、set_item_search_queryのプライベートメソッドを定義する必要があって、DRY原則に反する(何より記述が面倒)。

###1-2. 対策

結論としては、application_controller.rbにメソッドを定義して、それを必要な各コントローラーで呼び出した。

application_controller.rb
class ApplicationController < ActionController::Base
~~
  def set_item_search_query
    @q = Item.ransack(params[:q])
    @items = @q.result(distinct: true)
  end
~~
end
tops_controller.rb
class TopsController < ApplicationController
  skip_before_action :authenticate_user!
  before_action :set_item_search_query  # この記述を追加

  def index  
    @items = Item.including.limit(5).desc.trading_not
  end
  
  # 以下の記述は削除
  # private
  # def set_item_search_query
  #   @q = Student.ransack(params[:q])
  #   @items = @q.result(distinct: true)
  # end

end

###1-3. 残課題(というより理解不足)
set_item_search_queryメソッドをapplication_controller.rb内の、privateの上下どちらに定義しても、他のコントローラーから呼び出せた。

privateは本来、他のコントローラーで呼び出されたくないメソッドを定義するためのもの(という認識)なので、これはどういうことなんだろう、、と疑問に思って調査中です。

##2. 検索結果の並び替えの実装
###2-1. 課題:関連する子モデルの多い順での並び替えをどう実現するか

Itemsに紐づく「お気に入り!」の多い順(関連する子モデルの多い順)での並び替えに苦戦。
それ以外の下記の並び替えは、単にItemモデルのカラムに対して、orderでソートするだけなので、比較的簡単に実装できた。

  • 価格の安い順
  • 価格の高い順
  • 出品の新しい順
  • 出品の古い順

ビュー、コントローラーは下記の通り。
簡単に説明すると、jsでプルダウンが変更されたときにイベントを発火して、選択した値をsortパラメーターとしてコントローラに渡している。

各コード(クリックして開く)
search.html.haml
.sc-side__sort
  %select{name: :sort_order, class: 'sort-order .sc-side__sort__select'}
    %option{value: "location.pathname", name: "location.pathname"}
      並び替え
    %option{value: "price-asc"}
      価格の安い順
    %option{value: "price-desc"}
      価格の高い順
    %option{value: "created_at-asc"}
      出品の古い順
    %option{value: "created_at-desc"}
      出品の新しい順
    %option{value: "likes-desc"}
      お気に入り!の多い順
items_controller.rb
class ItemsController < ApplicationController
  skip_before_action :authenticate_user!, except: [:new, :edit, :create, :update, :destroy]
  before_action :set_item_search_query, expect: [:search]
~~
  def search
    sort = params[:sort] || "created_at DESC"      
    @q = Item.includes(:images).(search_params)
    @items = @q.result(distinct: true).order(sort)
  end
~~
end
item_search.js
// 並び替えの挙動
$(function() {
  // プルダウンメニューを選択することでイベントが発生
  $('select[name=sort_order]').change(function() {
    // 選択したoptionタグのvalue属性を取得する
    const sort_order = $(this).val();
    // value属性の値により、ページ遷移先の分岐

    switch (sort_order) {
      case 'price-asc': html = "&sort=price+asc"; break;
      case 'price-desc': html = "&sort=price+desc"; break;
      case 'created_at-asc': html = "&sort=created_at+asc"; break;
      case 'created_at-desc': html = "&sort=created_at+desc"; break;
      case 'likes-desc': html = "&sort=likes_count_desc"; break;
      default: html = "&sort=created_at+desc"; 
    }
    // 現在の表示ページ
    let current_html = window.location.href;
    // ソート機能の重複防止 
    if (location['href'].match(/&sort=*.+/) != null) {
      var remove = location['href'].match(/&sort=*.+/)[0]
      current_html = current_html.replace(remove, '')
    };
    // ページ遷移
    window.location.href = current_html + html
  });
  // ページ遷移後の挙動
  $(function () {
    if (location['href'].match(/&sort=*.+/) != null) {
      // option[selected: 'selected']を削除
      if ($('select option[selected=selected]')) {
        $('select option:first').prop('selected', false);
      }

      const selected_option = location['href'].match(/&sort=*.+/)[0].replace('&sort=', '');

      switch (selected_option) {
        case "price+asc": var sort = 1; break;
        case "price+desc": var sort = 2; break;
        case "created_at+asc": var sort = 3; break;
        case "created_at+desc": var sort = 4; break;
        case "likes_count_desc": var sort = 5; break;
        default: var sort = 0
      }
      const add_selected = $('select[name=sort_order]').children()[sort]
      $(add_selected).attr('selected', true)
    }
  });
});

###2-2. 対策
コントローラーに渡すパラメータには、子モデルの多い順を表現するコードを渡せないと思ったので、コントローラー側で条件分岐させることに。

items_controller.rb
class ItemsController < ApplicationController
  ~~
  def search
    @q = Item.includes(:images).search(search_params)
    sort = params[:sort] || "created_at DESC"    
    # jsから飛んできたパラーメーターが"likes_count_desc"の場合に、子モデルの多い順にソートする記述を  
    if sort == "likes_count_desc"
      @items = @q.result(distinct: true).select('items.*', 'count(likes.id) AS likes')
        .left_joins(:likes)
        .group('items.id')
        .order('likes DESC').order('created_at DESC')
    else
      @items = @q.result(distinct: true).order(sort)
    end
  end
  ~~
end

###2-3. 残課題
もっといい記述がありそう、、、分かる方教えていただきたいです。

##3. セレクトボックスのビューでの表現
###3-1. 課題:「価格帯」のように、モデルのプロパティとは軸が異なる選択肢をどう実現するか?

53b473176e29ae82957ead2f8d843b37.gif

↑こんな感じで、価格帯を選択したら下限と上限がそれぞれカラムに入る機能を実現したい。
ただ、Itemが持ってるプロパティはprice(:integer型)なので、それは使えなさそう。

###3-2. 対策
active_hashで、ハッシュのデータをモデル(ActiveRecord)と同じ感覚で扱えるものを作成。

最初から「active_hashを使おう!」と思ったわけではなく、

  • options_from_collection_for_selectのヘルパーメソッドに渡せる配列必要
  • しかも選択した内容に応じて、minとmaxに渡す値変える必要がある
  • これをjsで記述するの面倒
  • モデル使えないかな?でもわざわざモデル作ってテーブルまで作るの面倒

と思って消去法でactive_hashを使用。

active_hashについて参考にした記事はこちら ⇒ active_hashまとめ

具体的に行ったのは、price_rangeというモデル(として扱えるもの)を作成して、それをビューに表示。

①app/modelsに、手動でファイルを作成して下記を記入

price_range.rb
class PriceRange < ActiveHash::Base
  self.data = [
      {id: 1, name: '¥300〜¥1,000', min: 300, max: 1000},
      {id: 2, name: '¥1,000〜¥5,000', min: 1000, max: 5000},
      {id: 3, name: '¥5,000〜¥10,000', min: 5000, max: 10000},
      {id: 4, name: '¥10,000〜¥30,000', min: 10000, max: 30000},
      {id: 5, name: '¥30,000〜¥50,000', min: 30000, max: 50000},
  ]
end

※本来のactive_hashならItemモデルと紐付けますが、今回のprice_rangeはItemモデルと紐づける必要がないので、belongs_to_active_hashの記述は省いています。

②ビューファイルで配列データを作成し、options_from_collection_for_selectで表示

search.html.haml
~~
# 価格の検索条件
.sc-side__detail__field
  %h5.sc-side__detail__field__label
    %i.fas.fa-search-dollar
    = f.label :price, '価格'
  .sc-side__detail__field__form
    # PriceRange.allで、active_hashで定義したデータ(ハッシュ)を配列として取得
    # それを、options_from_collection_for_selectのヘルパーメソッドで展開してプルダウンに
    = select_tag :price_range, options_from_collection_for_select(PriceRange.all, :id, :name), { prompt: "選択してください"}
  .sc-side__detail__field__form.price-range
    = f.number_field :price_gteq, placeholder: "¥ Min", class: 'price-range__input'
    .price-range__swang 
    = f.number_field :price_lteq, placeholder: "¥ Max", class: 'price-range__input'
~~

###3-3. 残課題
本来のactive_hashの使い方ではない気がする。。。

##4. チェックボックスのビューでの表現

###4-1. 課題:モデルを持たない選択肢をどう表現するか

Itemモデル(商品)のプロパティのうち、選択肢となるのは下記。
大体の選択肢がactive_hashでモデル(として扱えるもの)を作成している。

①商品の状態 (status_id) → active_hashでモデル?作成
②配送料の負担 (delivery_charge_flag) → ビューで選択肢を直接記述
③販売状況(trading_status_id) → active_hashでモデル?作成
④カテゴリー (category_id) → categoriesテーブルあり

①以外はすべて困った。

####4-1-1. ①商品の状態 (status_id) → active_hashでモデル?作成

課題の詳細の前にまずは①の記述はこちら。(クリックして開く)
status.rb
# active_hashです
class Status < ActiveHash::Base
  self.data = [
      {id: 1, name: '新品、未使用'},
      {id: 2, name: '未使用に近い'},
      {id: 3, name: '目立った傷や汚れなし'},
      {id: 4, name: 'やや傷や汚れあり'},
      {id: 5, name: '傷や汚れあり'},
      {id: 6, name: '全体的に状態が悪い'},
  ]
end
item.rb
class Item < ApplicationRecord
~~
  belongs_to_active_hash :status
~~
search.html.haml
~~
# 商品の状態の検索条件
.sc-side__detail__field
  %h5.sc-side__detail__field__label
    %i.fas.fa-star
    = f.label :status_id_eq, '商品の状態'
  .sc-side__detail__field__form.checkbox-list
    .sc-side__detail__field__form--checkbox.js_search_checkbox-all
      .sc-side__detail__field__form--checkbox__btn
        %input{type: 'checkbox', id: 'status_all', class: 'js-checkbox-all'}
      .sc-side__detail__field__form--checkbox__label
        = label_tag :status_all, 'すべて'
    = f.collection_check_boxes :status_id_in, Status.all, :id, :name, include_hidden: false do |b|
      .sc-side__detail__field__form--checkbox
        .sc-side__detail__field__form--checkbox__btn.js_search_checkbox
          = b.check_box
        .sc-side__detail__field__form--checkbox__label
          = b.label { b.text}
~~

この記述で、「すべて」まで含めたチェックボックス群を作成できる。
スクリーンショット 2020-06-09 21.25.33.png

この記述で、選択肢をチェックボックスで表示することも、選択して検索後の画面でも、チェックボックスが保持されたまま表示される。
@qインスタンスに、検索条件がパラメータとして保持されてコントローラーに飛び、検索後に返ってくる@qには、そのパラメータを含んでいるため)

####4-1-2. ②配送料の負担 (delivery_charge_flag) → ビューで選択肢を直接記述
困った要因は、商品出品(item/new)時のフォームでは、配送料の負担(出品者負担か、購入者負担か)は、単にビューに選択肢を書いたのみ。

views/items/_form.html.haml
~~
.field__form.item-area__field__form
  = f.select :delivery_charge_flag, [['送料込み(出品者負担)', '1'], ['着払い(購入者負担)', '2']], include_blank: '選択してください', required: :true
~~

しかも、flag(1 or 2)に応じて、配送の方法を変更するjsを別で記述してしまっているので、ここを大幅変更すると、影響範囲が大きいし予期せぬエラーが出そう。

つまり、①と同じようなactive_hashの使い方ができなさそう

####4-1-3. ③販売状況(trading_status_id) → active_hashでモデル?作成
ここが意外と厄介でした。

当初は、trading_statusは下記で設計していた。

trading_status.rb
class TradingStatus < ActiveHash::Base
  self.data = [
      {id: 1, name: '販売中'},
      {id: 2, name: '取引中'},
      {id: 3, name: '売却済み'},
      {id: 4, name: '下書き'}
  ]
end

これを見る限りは、「販売中」はidが1、「売り切れ」はidが3、と単純そうだがそうではない。
「取引中(id:2)」も、「売り切れ」に含まれる。(販売中ではない)

つまり、active_hashで取得した配列はそのままでは検索条件としては使えない、ということ。

このステータスを変更しようとすると、ここも影響部分が広いので、漏らさず修正する時間もなかった。
(最初の設計をもっとちゃんとするべきだった)

※④カテゴリーについては後述

###4-2. 対策

####②への対応
これは意外と単純だった。
active_hashでのデータ定義の際、idではなくflagを使えば、①と同じようにactive_hashを使用できた。

delivery_charge.rb
class DeliveryCharge < ActiveHash::Base
  self.data = [
      {flag: 1, name: '送料込み(出品者負担)'},
      {flag: 2, name: '着払い(購入者負担)'}
  ]
end

####③への対応
ここはcontrollerでゴリ押しした感じです。
なのでめちゃくちゃわかりにくいです。

やったことを箇条書きにすると

  • 検索条件画面で、選択肢をチェックボックス表示するための配列(@trading_status)を作成 → これは単に検索条件を表示するためだけのインスタンス
  • 一旦、ransackの通常の検索を行う(この時「販売状況」は検索条件になっていない) ・・・A
  • 「販売状況」が検索条件として指定された時(params.require(:q)[:trading_status_id_in]がtrueの時)に行う処理を条件分岐する
  • 検索後の画面には、検索条件として指定した「販売状況」を返す必要があるので、@qを再定義
  • 再定義用のプライベートメソッドを定義して使用
  • 検索条件として指定された「販売状況」の数が1つの時で処理を分岐 → 2つの時は、「販売中」「売り切れ」の両方を指定している=「販売状況」は検索の条件とはならない
  • 「売り切れ」を指定された時は、buyer_idがnot nilのitemsを取得 ・・・B
  • ABとで共通するitemsのみを抽出して、@itemsに定義
  • つまり、「販売状況」以外の検索条件で検索された@itemsと、「売り切れ」であるsold_itemsを比較して、一致するitemsのみを取得している
  • これと同じことを、「販売中」(=buyer_idがnil)でも行う
items_controller.rb
~~
def search
  #「販売状況」の検索条件を表示するために、配列となるインスタンスを定義
  # idが1と3 = 販売中、売却済み
  @trading_status = TradingStatus.find [1,3]

  # 一旦、ransackの通常の検索を行う(この時「販売状況」は検索条件になっていない)
  sort = params[:sort] || "created_at DESC"      
  @q = Item.not_draft.search(search_params)
  @items = @q.result(distinct: true).order(sort)
~~
  # 販売状況が検索条件にあるとき
  if trading_status_key = params.require(:q)[:trading_status_id_in]
    # 検索後の画面に返す@qの再定義
    @q = Item.includes(:images).search(search_params_for_trading_status)
    # 指定された販売条件が1つ(2つ指定だと、販売中と売り切れの両方を指定=指定してない)
    # かつ、指定されたキーが3=「売り切れ」の時の処理
    if trading_status_key.count == 1 && trading_status_key == ["3"]
      # 売り切れのItemを取得 → 僕のチームでは、itemsテーブルのbuyer_idに値がある=売り切れ、という定義でした
      sold_items = Item.where.not(buyer_id: nil)
      # 上の@itemsは、ransackで受け取った検索条件で検索した@items
      # それと、sold_itemsで一致するitemを抽出している → それが&
      @items = @items & sold_items
    elsif trading_status_key.count == 1 && trading_status_key == ["1"]
      # 販売中のItemを取得 → 僕のチームでは、itemsテーブルのbuyer_idがnil=販売中、という定義でした
      selling_items = Item.where(buyer_id: nil)
      @items = @items & selling_items
    end
  end

  private
  #「販売状況」が検索条件として指定されないときの@q → ransackではこの検索条件で検索をしている
  def search_params
    params.require(:q).permit(
      :name_or_explanation_cont,
      :category_id,
      :price_gteq,
      :price_lteq,
      category_id_in: [],
      status_id_in: [],
      delivery_charge_flag_in: [],
    )
  end

  # 「販売状況」が検索条件として指定されたときの@q
  def search_params_for_trading_status
    params.require(:q).permit(
      :name_or_explanation_cont,
      :category_id,
      :price_gteq,
      :price_lteq,
      category_id_in: [],
      status_id_in: [],
      delivery_charge_flag_in: [],
      trading_status_id_in: [],
    )
  end
end
~~

###4-3. 残課題
かなり複雑にしてしまった部分。
最初の設計が大事だと痛感した一コマでした。
これ以外にいい方法があれば見つけたい、、!

重大?な残課題が、

  • @items[]でない
  • @items = @items & selling_items@items = @items & sold_itemsの返り値が[](=つまり該当するitemsがない)

という条件の時、bulletが反応してしまう。

スクリーンショット 2020-06-09 23.30.11.png

AVOID(避けろ!)なので、実際にN+1問題は起きていなさそうなのだが、、、
英語文を噛み砕くと、「必要ないところでeager loadingが検出されたから、回避して」ってことなのかと。
ただ、この指定の通りに.includes(:images)を除いてしまうと、お察しの通り他のパターンの時にUSE(使え!)のアラートが出てくる。

ここは条件分岐でもうまく行かなかったので、一旦諦める。

##5. ancestryを使用する項目とransackとの関係
###5-1. 課題:カテゴリーをどう検索条件としてコントローラーに送るか
カテゴリーは、親・子・孫に分かれている。
itemsテーブルには、category_idのみがあり、ここで選択されるcategory_idは、孫カテゴリのid。

一方で検索の方法としては、「親カテゴリだけ選択した場合」「子カテゴリまで選択した場合」で検索できた方が圧倒的に便利。これをどう実現するか?が課題。

もっと課題を細分化すると

①孫カテゴリは、プルダウンではなくチェックボックスにしたい
②検索後の検索条件欄に、「親カテゴリのみ選択した場合」、「子カテゴリまで選択した場合」の条件をうまく返せない
category_id_inだけ受け取るフォームにしてしまうと、親カテゴリ・子カテゴリでの検索ができない
④親カテゴリのみ選択した状態でフォームを送信すると、category_idがブランクの状態でパラメータが飛んでしまう

多い…

####5-1-1. ①孫カテゴリは、プルダウンではなくチェックボックスにしたい

孫カテゴリは1つだけ選択できる状態(プルダウン)↓ではなく、
c2a4191736e7b580941b142cb934587d.gif

孫カテゴリを複数選択できる状態(チェックボックス)↓にしたい
d97b6e77fc3888c783c49f51b73c1304.gif

####5-1-2. ②検索後の検索条件欄に、「親カテゴリのみ選択した場合」、「子カテゴリまで選択した場合」の条件をうまく戻せない
例えばこう指定して検索すると、
スクリーンショット 2020-06-09 23.54.22.png

検索後の条件欄はなにも指定しなかったような状態になってしまう(子カテゴリまで選択した場合も同様)
スクリーンショット 2020-06-09 23.55.18.png

####5-1-3. ③category_id_inだけ受け取るフォームにしてしまうと、親カテゴリ・子カテゴリでの検索ができない

最初のビューの記述はこうしていたが、これだと孫カテゴリまで選択しないと、カテゴリが検索条件として機能しなかった。

search.html.haml
%li
  = f.select :category_id_in ,options_for_select(@parents, @item.category.root.name),{include_blank: "選択してください"}, {id: 'parent_category', required: :true}
%li
  = f.select :category_id_in ,options_for_select(@category_child_array.map{|c|[c[:name], c[:id]]}, @item.category.parent.id),{include_blank: "選択してください"}, {id: 'children_category', required: :true}
%li
  = f.select :category_id_in ,options_for_select(@category_grandchild_array.map{|c|[c[:name], c[:id]]}, @item.category.id),{include_blank: "選択してください"}, {id: 'grandchildren_category', required: :true}

パラメータで受け取るcategory_id_inで検索できるのは孫カテゴリだけなので、この状態で親カテゴリだけ選択しても該当する商品はない。(「メンズ」だけ選択しても、メンズを親カテゴリとする商品は検索できない。)

####5-1-4. ④親カテゴリのみ選択した状態でフォームを送信すると、category_idがブランクの状態でパラメータが飛んでしまう

③の対策で、親カテゴリと子カテゴリのプロパティをcategory_idとしたが(理由は後述)、子カテゴリ未選択の状態で検索実行すると、category_idがブランクの状態で飛んでしまう。

###5-2. 対策
jsとコントローラーでゴリ押しです。

####5-2-1. ①孫カテゴリは、プルダウンではなくチェックボックスにしたいへの対応
ビューとjsを下記のようにして解決。

コードはクリックして表示
search.html.haml
.sc-side__detail__field
  %h5.sc-side__detail__field__label
    %i.fas.fa-list-ul
    = f.label :category_id, 'カテゴリーを選択する'
  .sc-side__detail__field__form
    %ul.field__input--category_search
      - if @search_category.present?
        %li
          = f.select :category_id, options_for_select(@search_parents, @search_category.root.name),{include_blank: "選択してください"}, {id: 'parent_category_search'}
        %li
          - if @category_child.present?
            = f.select :category_id, options_for_select(@category_child_array, @category_child.id),{include_blank: "選択してください"}, {id: 'children_category_search'}
          - else
            = f.select :category_id, @category_child_array, {include_blank: "選択してください"}, {id: 'children_category_search'}
        - if @category_grandchild_array.present?
          %li#grandchildren_category_checkboxes.checkbox-list
            .sc-side__detail__field__form--checkbox.js_search_checkbox-all
              .sc-side__detail__field__form--checkbox__btn
                %input{type: 'checkbox', id: 'grandchildren_category_all', class: 'js-checkbox-all'}
              .sc-side__detail__field__form--checkbox__label
                = label_tag :grandchildren_category_all, 'すべて'
            = f.collection_check_boxes :category_id_in, @category_grandchild_array, :id, :name, include_hidden: false do |b|
              .sc-side__detail__field__form--checkbox
                .sc-side__detail__field__form--checkbox__btn.js_search_checkbox
                  = b.check_box
                .sc-side__detail__field__form--checkbox__label
                  = b.label { b.text}
      - else
        %li
          = f.select :category_id, @search_parents, {include_blank: "選択してください"}, {id: 'parent_category_search'}

item_search.js
  // 検索フォームでのカテゴリーの挙動
  function appendOption(category){
    let html = `<option value="${category.id}" >${category.name}</option>`;
    return html;
  }

  function appendCheckbox(category){
    let html =`
                <div class="sc-side__detail__field__form--checkbox">
                  <div class="sc-side__detail__field__form--checkbox__btn js_search_checkbox">
                    <input type="checkbox" value="${category.id}" name="q[category_id_in][]" id="q_category_id_in_${category.id}" >
                  </div>
                  <div class="sc-side__detail__field__form--checkbox__label">
                    <label for="q_category_id_in_${category.id}">${category.name}</label>
                  </div>
                </div>
                `
    return html;
  }

  // 子カテゴリーの表示作成
  function appendChildrenBox(insertHTML){
    const childSelectHtml = `<li>
                              <select id="children_category_search" name="q[category_id]">
                                <option value="">選択してください</option>
                                ${insertHTML}
                              </select>
                            </li>`;
    $('.field__input--category_search').append(childSelectHtml);
  }
  // 孫カテゴリーの表示作成
  function appendGrandchildrenBox(insertHTML){
    const grandchildSelectHtml =`
                                <li id="grandchildren_category_checkboxes" class="checkbox-list">
                                  <div class="sc-side__detail__field__form--checkbox js_search_checkbox-all">
                                    <div class="sc-side__detail__field__form--checkbox__btn">
                                      <input class="js-checkbox-all" id="grandchildren_category_all" type="checkbox">
                                    </div>
                                    <div class="sc-side__detail__field__form--checkbox__label">
                                      <label for="grandchildren_category_all">すべて</label>
                                    </div>
                                  </div>
                                  ${insertHTML}
                                </li>`;
    $('.field__input--category_search').append(grandchildSelectHtml);
  }
  // 親カテゴリー選択後のイベント
  $('#parent_category_search').on('change', function(){
    //選択された親カテゴリーの名前を取得
    const parentName =$(this).val(); 
    if (parentName != ""){ 
      //親カテゴリーが初期値でないことを確認
      $.ajax({
        url: '/items/category_children',
        type: 'GET',
        data: { parent_name: parentName },
        dataType: 'json'
      })
      .done(function(children){
         //親が変更された時、子以下を削除する
        $('#children_category_search').remove();
        $('#grandchildren_category_checkboxes').remove();
        let insertHTML = '';
        children.forEach(function(child){
          insertHTML += appendOption(child);
        });
        appendChildrenBox(insertHTML);
      })
      .fail(function(){
        alert('カテゴリー取得に失敗しました');
      })
    }else{
      //親カテゴリーが初期値になった時、子以下を削除する
      $('#children_category_search').remove();
      $('#grandchildren_category_checkboxes').remove();
    }
  });
  // 子カテゴリー選択後のイベント
  $('.field__input--category_search').on('change', '#children_category_search', function(){
    const childId = $(this).val();
    //選択された子カテゴリーのidを取得
    if (childId != ""){ 
      //子カテゴリーが初期値でないことを確認
      $.ajax({
        url: '/items/category_grandchildren',
        type: 'GET',
        data: { child_id:  childId},
        dataType: 'json'
      })
      .done(function(grandchildren){
        if (grandchildren.length != 0) {
          //子が変更された時、孫以下を削除する
          $('#grandchildren_category_checkboxes').remove();
          let insertHTML = '';
          grandchildren.forEach(function(grandchild){
            insertHTML += appendCheckbox(grandchild);
          });
          appendGrandchildrenBox(insertHTML);
        }
      })
      .fail(function(){
        alert('カテゴリー取得に失敗しました');
      })
    }else{
      $('#grandchildren_category_checkboxes').remove();
    }
  });

####5-2-2. ②③への対応

親カテゴリと子カテゴリのフォームのプロパティ名を、category_idにして、コントローラーで処理。
ビューは上述のもの。

コントローラーはこちらをクリック
items_controller.rb
  def search
    @trading_status = TradingStatus.find [1,3]
    @keyword = params.require(:q)[:name_or_explanation_cont]
    @search_parents = Category.where(ancestry: nil).where.not(name: "カテゴリー一覧").pluck(:name)

    sort = params[:sort] || "created_at DESC"      
    @q = Item.not_draft.search(search_params)
    if sort == "likes_count_desc"
      @items = @q.result(distinct: true).select('items.*', 'count(likes.id) AS likes')
        .left_joins(:likes)
        .group('items.id')
        .order('likes DESC')
        .desc
    else
      @items = @q.result(distinct: true).order(sort)
    end
    # 販売状況が検索条件にあるとき
    if trading_status_key = params.require(:q)[:trading_status_id_in]
      @q = Item.including.search(search_params_for_trading_status)
      if trading_status_key.count == 1 && trading_status_key == ["3"]
        sold_items = Item.where.not(buyer_id: nil)
        @items = @items & sold_items
      elsif trading_status_key.count == 1 && trading_status_key == ["1"]
        selling_items = Item.where(buyer_id: nil)
        @items = @items & selling_items
      end
    end

    # カテゴリが検索条件にあるとき
    if category_key = params.require(:q)[:category_id]
      if category_key.to_i == 0
        @search_category = Category.find_by(name: category_key, ancestry: nil)
      else
        @search_category = Category.find(category_key)
      end

      if @search_category.present?
        if @search_category.ancestry.nil?
          #親カテゴリ
          @category_child_array = Category.where(ancestry: @search_category.id).pluck(:name, :id)
          grandchildren_id = @search_category.indirect_ids.sort
          find_category_item(grandchildren_id)
        elsif @search_category.ancestry.exclude?("/")
          #子カテゴリ
          @category_child = @search_category
          @category_child_array = @search_category.siblings.pluck(:name, :id)
          @category_grandchild_array = @search_category.children
          grandchildren_id = @search_category.child_ids
          find_category_item(grandchildren_id)
        end
          #孫カテゴリはransackで拾う → category_id_in
      end
    end
    @items = Kaminari.paginate_array(@items).page(params[:page]).per(20)
  end

####5-2-3. ④親カテゴリのみ選択した状態でフォームを送信すると、category_idがブランクの状態でパラメータが飛んでしまうへの対応

jsで、検索実行時に子カテゴリがブランク(未選択)の時、子カテゴリのプルダウンをremoveする処理で対応

item_search.js
  $('#detail_search_btn').click(function(e) {
    if ($('#children_category_search').val() == "") {
      $('#children_category_search').remove();
    }
  });

###5-3. 残課題
4-3と同様、bulletが反応してしまう時がある、、、

また、親カテゴリだけ選択した状態(子カテゴリが選択されていない状態)で検索を実行(=フォームを送信)すると、category_idのパラメータがブランクでコントローラーに渡されてしまう。
今回はjsで子カテゴリのf.selectを削除する方法をとったが、もっといい方法がありそう。

##6. JavaScriptで「すべて」ボタンの実装

###6-1. 課題:意外と複雑な「すべて」の実現

  • 「すべて」を押した時は該当の選択肢を全選択
  • 該当の選択肢から一つでもチェックが外れたら「すべて」も外す
  • 「すべて」以外の選択肢が全て選択されたら「すべて」もチェック
  • 検索後の画面で、「すべて」以外の選択肢が全て選択されていたら「すべて」もチェック

という意外と複雑な要件をどう実現するか、でハマりました。
↓こんな感じの挙動
836dea5b6925b9d31ed861810c3352d6.gif

###6-2. 対策
jsで実装。

簡単に処理を分けると大きく3つです。
①「すべて」をクリックした時の挙動
②「すべて」以外をクリックした時の挙動
③ページ読み込み時の、チェックボックス「すべて」にチェックを入れるか判定する関数

③の処理を書いておかないと、検索条件で選択肢を全て選択した状態で検索実行→検索後の条件欄からは「すべて」のチェックボックスが外れてしまっている状態になって、不格好です。

コードはこちらをクリックしてください。
item_search.js
$(function(){
  const min_price = $('#q_price_gteq');
  const max_price = $('#q_price_lteq');
  let grandchild_category_all_checkbox = $('#grandchildren_category_all');
  let grandchild_category_checkboxes = $('input[name="q[category_id_in][]"]');
  const status_all_checkbox = $('#status_all');
  const status_checkboxes = $('input[name="q[status_id_in][]"]')
  const delivery_charge_all_checkbox = $('#delivery_charge_flag_all')
  const delivery_charge_checkboxes = $('input[name="q[delivery_charge_flag_in][]"]')
  const trading_status_all_checkbox = $('#trading_status_all')
  const trading_status_checkboxes = $('input[name="q[trading_status_id_in][]"]')

  // ①「すべて」をクリックした時の挙動
  $(document).on('change', '.js-checkbox-all', function() {
    function targetCheckboxesChage(target, trigger) {
      if (trigger.prop("checked") == true) {
        target.prop("checked", true);
      } else {
        target.prop("checked", false);
      }
    }

    let target_checkboxes;
    switch ($(this).prop('id')) {
      case $('#grandchildren_category_all').prop('id'):
        target_checkboxes = $('input[name="q[category_id_in][]"]');
        break;
      case status_all_checkbox.prop('id'):
        target_checkboxes = status_checkboxes;
        break;
      case delivery_charge_all_checkbox.prop('id'):
        target_checkboxes = delivery_charge_checkboxes;
        break;
      case trading_status_all_checkbox.prop('id'):
        target_checkboxes = trading_status_checkboxes;
        break;
      default: ;
    }
    targetCheckboxesChage(target_checkboxes, $(this));
  });

  // ②「すべて」以外をクリックした時の挙動
  $(document).on('change', '.js_search_checkbox > input:checkbox', function() {
    function allCheckboxChange(target, all_checkbox, trigger) {
      if (trigger.prop("checked") == false) {
        all_checkbox.prop("checked", false);
      } else {
        let flag = true
        target.each(function(e) {
          if (target.eq(e).prop("checked") == false) {
            flag = false;
          }
        });
        if (flag) {
          all_checkbox.prop("checked", true);
        }
      }
    }  
    let all_checkbox;
    grandchild_category_all_checkbox = $('#grandchildren_category_all');
    grandchild_category_checkboxes = $('input[name="q[category_id_in][]"]');
    switch ($(this).prop('name')) {
      case grandchild_category_checkboxes.prop('name'):
        target_checkboxes = grandchild_category_checkboxes;
        all_checkbox = grandchild_category_all_checkbox;
        break;
      case status_checkboxes.prop('name'):
        target_checkboxes = status_checkboxes;
        all_checkbox = status_all_checkbox;
        break;
      case delivery_charge_checkboxes.prop('name'):
        target_checkboxes = delivery_charge_checkboxes;
        all_checkbox = delivery_charge_all_checkbox;
        break;
      case trading_status_checkboxes.prop('name'):
        target_checkboxes = trading_status_checkboxes;
        all_checkbox = trading_status_all_checkbox;
        break;
      default: ;
    }
    allCheckboxChange(target_checkboxes, all_checkbox, $(this));
  });


  // ページ読み込み時の、チェックボックス「すべて」にチェックを入れるか判定する関数
  function loadCheckboxSlection(target, all_checkbox) {
    let flag = true;
    target.each(function(e) {
      if (target.eq(e).prop("checked") == false) {
        flag = false;
      }
    });
    if (flag) {
      all_checkbox.prop("checked", true);
    }
  }

  // ③ページ読み込み時に、チェックボックス「すべて」にチェックを入れるか判定する関数を走らせる
  if ($('#item_search_form').length) {
    loadCheckboxSlection(grandchild_category_checkboxes ,grandchild_category_all_checkbox)
    loadCheckboxSlection(status_checkboxes, status_all_checkbox)
    loadCheckboxSlection(delivery_charge_checkboxes, delivery_charge_all_checkbox)
    loadCheckboxSlection(trading_status_checkboxes, trading_status_all_checkbox)

    if (min_price.val() != "" && max_price.val() != "") {
      $.ajax({
        url: '/items/price_range',
        type: 'GET',
        data: { min: min_price.val(), max: max_price.val()},
        dataType: 'json'
      })
      .done(function(range) {
        if (range) {
          $('#price_range').val(range.id);
        }
      })
      .fail(function() {
        alert('価格帯の取得に失敗しました')
      })
    }
  }
});

###6-3. 残課題
記述が冗長な気がする、、、もう少しリファクタしたいので、いい書き方を模索中。

##7. 検索条件のクリアボタン
###7-1. 課題:「リセット」をjsで実装するしかない?記述が冗長になりそう

最初は、Railsのヘルパーメソッドでフォームを初期化(=リセット)するボタンを作った。
[Rails]フォームのすべての値をワンクリックで初期化する(helperメソッド定義+JavaScript)

ただこの方法だと、一度検索条件を指定して検索実行した後の画面だと、意図する動きができない。
※リセットは、その画面を読み込んだ際の値(=初期値)に戻す、という動き。

どういうことかというと、例えば「margiela」とキーワードで検索を実行した場合、検索を実行するとこの画面になる。
スクリーンショット 2020-06-10 11.49.49.png

初期値は画面が読み込まれたタイミングの値、と考えるとわかりやすいので、この状態のキーワードの初期値は「margiela」。
つまり、この状態でリセット(=フォームの初期化)を行なっても、キーワードに入っている「margiela」は消えない。初期値が「margiela」なので(キーワードを「yohji yamamoto」に書き換えて「初期化」を行うと「margiela」に戻る)。

実現したいのはそうではなく、値をすべてブランク(プルダウンの場合は「選択してください」)に戻す動き。

###7-2. 対策
jsで実装。
項目一つずつに対して処理を走らせる記述だと冗長になるので、フォームの子要素を探しに行く記述にした。

item_search.js
// clearボタンを押した時の動作
$(function () {
  $("#js_conditions_clear").on("click", function () {
      clearForm(this.form);
  });

  function clearForm (form) {
    $(form)
        .find("input, select, textarea")
        .not(":button, :submit, :reset, :hidden")
        .val("")
        .prop("checked", false)
        .prop("selected", false)
    ;
    $('select[name=sort_order]').children().first().attr('selected', true);
    $('#children_category_search').remove();
    $('#grandchildren_category_checkboxes').remove();
  }
});

###7-3. 残課題
もっといい方法がありそうな気もする。。模索します。

#おわりに
Railsの理解が甘いこともあって、もっと王道の方法がありそうな気もしています。
もし「こういう書き方の方が綺麗!」というものがありましたら、お知らせいただけますと幸甚です。

27
28
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
27
28

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?