0
0

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.

[rails6] 動的なカテゴリー機能の実装 後半(ancestry)

Last updated at Posted at 2021-10-09

はじめに

前回、こちらからカテゴリー機能の実装をまとめてみた。

  • 実装した機能は下記の通り。
    • ancestryによるデータの多階層化
    • 非同期通信(Ajax)を用いた動的なセレクトボックスの実装

前回の機能だけではカテゴリーに所属するpostを絞り込み表示する機能がない。
ここでは各カテゴリーに所属する投稿(post)を表示する機能を実装する。

サンプルGIF

Screen Recording 2021-10-09 at 03.06.11 PM

カテゴリー検索機能の実装

ルーティング

config/routes.rb
  resources :posts do
    collection do
      get "menu_search"
    end
    member do
      get "item_search"
    end
  end

collection内にget "menu_search"を追加。
member内にget item_searchを追加。

パスは下記の通り。
~/posts/menu_search
~/posts/:id/item_search

menu_searchはカテゴリーのリストを表示するためのリクエスト。
item_searchはカテゴリーリストから選択したカテゴリーに紐づくpostを参照するリクエスト。

独自アクションの定義

app/controllers/posts_controller.rb
  def menu_search
    respond_to do |format|
      format.html
      format.json do
        if params[:parent_id]
          @childrens = Category.find(params[:parent_id].to_s).children
        elsif params[:children_id]
          @grandChilds = Category.find(params[:children_id].to_s).children
        elsif params[:gcchildren_id]
          @parents = Category.where(id: params[:gcchildren_id].to_s)
        end
      end
    end
  end

private

マウス操作によってカテゴリーリストを表示させるために必要なインスタンス変数を条件分岐する。

respond_toでリクエストされるフォーマットがhtmlとjsonの場合で処理を分けている。

format.jsonの処理が行われる場合、渡ってきた値によって格納された値の階層下をインスタンス変数に定義する。

params[:xxxxx_id].to_sはjsで記述したajaxで渡ってくる値を取得する。

childrenはancestryが提供するメソッド。子レコードを取得する。

ここのインスタンス変数はmenu_search.json.jbuilderに渡り、JSON形式へと変換される。

app/controllers/posts_controller.rb
  def item_search
    #クリックしたカテゴリーidに紐づくレコードを取得
    @category = Category.find_by(id: params[:id])

    #親カテゴリーが選択された時
    if @category.ancestry.nil?
      #取得したレコードに紐づいた孫レコードidを取得
      category = Category.find_by(id: params[:id]).indirect_ids
      if category.empty?
        @posts = Post.where(category_id: @category.id).order(created_at: :desc)
      else
        @posts = []
        find_item(category)
      end

    #孫カテゴリーが選択された時
    elsif @category.ancestry.include?("/")
      @posts = Post.where(category_id: params[:id]).order(created_at: :desc)

    #親でも孫でもない=子カテゴリーが選択された時
    else
      #取得したレコードに紐づいた子レコードidを取得
      category = Category.find_by(id: params[:id]).child_ids
      @posts = []
      find_item(category)
    end
  end

  def find_item(category)
    category.each do |id|
      #子・孫レコードのidと一致するpostを取得
      post_array = Post.where(category_id: id).order(created_at: :desc)
      next unless post_array.present?

      post_array.each do |post|
        #一致して取得したpostを1つずつ取り出して@postsに順次追加
        @posts.push(post) if post.present?
      end
    end
  end

private

条件に一致するカテゴリーのidに紐づいたpostの一覧を取得するアクション。

item_searchでは、親or孫orそれ以外(子)を分岐させてカテゴリーidに紐づくpostを取得し、それをインスタンス変数に定義。

find_item(category)では、item_searchに必要なカテゴリーidに紐づいた空ではないpostを表示させるアクションを定義。

present?は配列の中に無駄な空の配列を含めないために条件分岐している。

下記はancestry独自のメソッド。
indirect_idsは、孫レコード以下のIDを返すメソッド。
child_idsは、子レコードのIDを取得するメソッド。

jbuilderファイルの作成

touch app/views/posts/menu_search.json.jbuilder

空のファイルを作成。
中身を書いていく。

app/views/posts/menu_search.json.jbuilder
json.array! @childrens do |child|
  json.id child.id
  json.name child.name
end
json.array! @grandChilds do |gc|
  json.id gc.id
  json.name gc.name
  json.root gc.root_id
end
json.array! @parents do |parent|
  json.parent parent.parent_id
end

前回同様、JSON形式に変換するための記述を行う。

javascriptファイルの作成

touch app/javascript/packs/category.js

空のファイルを作成。
中身を書いていく。

app/javascript/packs/category.js
$(document).on('turbolinks:load', function () {
  // 子カテゴリーを表示した時のhtml
  function childBuild(children) {
    let child_category = `
                        <li class="category_child">
                          <a href="/posts/${children.id}/item_search"><input class="child_btn" type="button" value="${children.name}" name= "${children.id}">
                          </a>
                        </li>
                        `;
    return child_category;
  }
  //孫カテゴリーを表示した時のhtml
  function gcBuild(children) {
    let gc_category = `
                        <li class="category_grandchild">
                           #posts/id/item_searchに
                          <a href="/posts/${children.id}/item_search"><input class="gc_btn" type="button" value="${children.name}" name= "${children.id}">
                          </a>
                        </li>
                        `;
    return gc_category;
  }

  // マウスホバー時に親カテゴリーを表示
  $('#categoBtn').hover(
    function (e) {
      e.preventDefault();                            //イベントに対するデフォルトの動作をキャンセル
      e.stopPropagation();                           //親要素に返るイベントの伝播をストップ
      timeOut = setTimeout(function () {
        $('#tree_menu').show();                      //500ms指定のidを表示
        $('.categoryTree').show();                    //500ms指定のclassを表示
      }, 500);
    },
    function () {
      clearTimeout(timeOut);
    }
  );

  // マウスホバー時に子カテゴリーを表示
  $('.parent_btn').hover(
    function () {
      $('.parent_btn').css('color', '');            //cssの適用を変更
      $('.parent_btn').css('background-color', '');
      let categoryParent = $(this).attr('name');    //要素(parent_btn)に紐づく属性(name)の値を取得
      timeParent = setTimeout(function () {
        $.ajax({
          url: '/posts/menu_search',
          type: 'GET',
          data: {
            parent_id: categoryParent,
          },
          dataType: 'json',
        })
          .done(function (data) {
            $('.categoryTree-grandchild').hide();   //孫カテゴリーの表示を隠す
            $('.category_child').remove();           //上で記述した子のhtmlを削除
            $('.category_grandchild').remove();    //上で記述した孫のhtmlを削除
            $('.categoryTree-child').show();              //子カテゴリーを表示
            data.forEach(function (child) {
              let child_html = childBuild(child);
              $('.categoryTree-child').append(child_html);  // html要素(子)を動的に追加
            });
            $('#tree_menu').css('max-height', '490px');
          })
          .fail(function () {
            alert('カテゴリーを選択してください');
          });
      }, 400);
    },
    function () {
      clearTimeout(timeParent);
    }
  );

  // 孫カテゴリーを表示
  $(document).on(
    {
      mouseenter: function () {                      //マウスが対象の要素の上に重なった時にイベント発火
        $('.child_btn').css('color', ''); 
        $('.child_btn').css('background-color', '');
        let categoryChild = $(this).attr('name');   //要素(child_btn)に紐づく属性(name)の値を取得
        timeChild = setTimeout(function () {
          $.ajax({
            url: '/posts/menu_search',
            type: 'GET',
            data: {
              children_id: categoryChild,
            },
            dataType: 'json',
          })
            .done(function (gc_data) {
              $('.category_grandchild').remove();   //上記に記述した孫のhtmlを削除
              $('.categoryTree-grandchild').show(); //孫カテゴリーを表示
              gc_data.forEach(function (gc) {
                let gc_html = gcBuild(gc);
                $('.categoryTree-grandchild').append(gc_html);  // html要素(孫)を動的に追加
                let parcol = $('.categoryTree').find(`input[name="${gc.root}"]`);  //classに含まれるinputに紐づいた孫のルートidを取得
                $(parcol).css('color', 'white');
                $(parcol).css('background-color', '#b1e9eb');
              });
              $('#tree_menu').css('max-height', '490px');
            })
            .fail(function () {
              alert('カテゴリーを選択してください');
            });
        }, 400);
      },
      mouseleave: function () {  //マウスが対象の要素の上から外れた時にイベント発火
        clearTimeout(timeChild);
      },
    },
    '.child_btn'
  );

  // 孫カテゴリーを選択時
  $(document).on(
    {
      mouseenter: function () {                  //マウスが対象の要素の上に重なった時にイベント発火
        let categoryGc = $(this).attr('name');   //要素(document)に紐づく属性(name)の値を取得
        timeGc = setTimeout(function () {
          $.ajax({
            url: '/posts/menu_search',
            type: 'GET',
            data: {
              gcchildren_id: categoryGc,
            },
            dataType: 'json',
          })
            .done(function (gc_result) {
              let childcol = $('.categoryTree-child').find(`input[name="${gc_result[0].parent}"]`);
              $(childcol).css('color', 'white');
              $(childcol).css('background-color', '#b1e9eb');
              $('#tree_menu').css('max-height', '490px');
            })
            .fail(function () {
              alert('カテゴリーを選択してください');
            });
        }, 400);
      },
      mouseleave: function () {    //マウスが対象の要素の上から外れた時にイベント発火
        clearTimeout(timeGc);
      },
    },
    '.gc_btn'
  );

  // カテゴリー一覧ページのボタン
  $('#all_btn').hover(
    function (e) {
      e.preventDefault();                       //イベントに対するデフォルトの動作をキャンセル
      e.stopPropagation();                      //親要素に返るイベントの伝播をストップ
      $('.categoryTree-grandchild').hide();
      $('.categoryTree-child').hide();
      $('.category_grandchild').remove();
      $('.category_child').remove();
    },
    function () {
      // 何も記述しない
    }
  );

  // カテゴリーを非表示
  $(document).on(
    {
      mouseleave: function (e) {
        e.stopPropagation();                           //親要素に返るイベントの伝播をストップ
        e.preventDefault();                            //イベントに対するデフォルトの動作をキャンセル
        timeChosed = setTimeout(function () {
          $('.categoryTree-grandchild').hide();
          $('.categoryTree-child').hide();
          $('.categoryTree').hide();
          $(this).hide();
          $('.parent_btn').css('color', '');
          $('.parent_btn').css('background-color', '');
          $('.category_child').remove();
          $('.category_grandchild').remove();
        }, 800);
      },
      mouseenter: function () {          //マウスが対象の要素の上に重なった時の処理
        clearTimeout(timeChosed);
      },
    },
    '#tree_menu'
  );

  // カテゴリーボタンの処理
  $(document).on(
    {
      mouseenter: function (e) {               //マウスが対象の要素の上に重なった時の
        e.stopPropagation();                   //親要素に返るイベントの伝播をストップ
        e.preventDefault();                    //イベントに対するデフォルトの動作をキャンセル
        timeOpened = setTimeout(function () {
          $('#tree_menu').show();
          $('.categoryTree').show();
        }, 500);
      },
      mouseleave: function (e) {               //マウスが対象の要素の上から外れた時にイベント発火
        e.stopPropagation();
        e.preventDefault();
        clearTimeout(timeOpened);
        $('.categoryTree-grandchild').hide();
        $('.categoryTree-child').hide();
        $('.categoryTree').hide();
        $('#tree_menu').hide();
        $('.category_child').remove();
        $('.category_grandchild').remove();
      },
    },
    '.header__headerInner__nav__listsLeft__item'
  );
});

リンクにマウスホバーした時の動作を定義。

javascriptの読み込みを有効化

require('./category');

読み込みにcategory.jsファイルを指定して有効にする。

ビュー

touch app/views/posts/_category.html.erb

html.erbファイルをパーシャルで作成。
カテゴリー表示をするビューの部分を個別に持たせる。

app/views/posts/_category.thml.rb
  <ul class='header__headerInner__nav__listsLeft'>
  <li class='header__headerInner__nav__listsLeft__item'>
    <%= link_to  posts_path, class: "category-button", id: 'categoBtn' do %>
      カテゴリーから探す
    <% end %>
    <div id='tree_menu'>
      <ul class='categoryTree'>
        <% @set_parents.each do |parent| %>
          <li class="category_parent">
            <%= link_to item_search_post_path(parent) do %>
              <input type="button" value="<%= parent.name %>" name="<%= parent.id %>" class="parent_btn">
            <% end %>
          </li>
        <% end %>
        <li class="category_parent">
            <%= link_to posts_path do %>
              <input type="button" value="全てのカテゴリー" id="all_btn">
            <% end %>
        </li>
      </ul>
      <ul class='categoryTree-child'></ul>
      <ul class='categoryTree-grandchild'></ul>
    </div>
  </li>
</ul>

ここの記述から親カテゴリーを表示し、マウスホバーから動的に子・孫カテゴリーをjavascriptで表示させる。

link_to item_search_post_path(parent) doの記述によってidに基づくリンク先を生成。
inputタグによって、ボタン形式のカテゴリー名とidを格納したリンクが作られる。

touch app/views/posts/item_search.html.erb

先ほど作ったパーシャルを持たせるhtml.erbを作成。

ルーティング先であったitem_searchをビューで作り、ここにカテゴリーを選択した際の絞り込み結果が表示されるようにする。

app/views/posts/item_search.html.erb
<%= render "category" %>
<%= render @posts %>

categoryは作ったパーシャルを読み込んでいる。
@postsはitem_searchアクションで取得したpost一覧を表示する。

あとは

app/views/posts/index.html.erb
<%= render "category" %>
<%= render @posts %>

ヘッダーに埋め込む方が現実的かとは思うが今回はindexにパーシャルを読み込ませる。

これで画面遷移した時に同じカテゴリー機能が表示されることになる。

ここの@postsはindexアクションにpost.allとした値を取得させる。
indexでは全てのpostをカテゴリーに関係なく一覧表示させる。

app/controllers/post_controller.rb
def index
  @posts = Post.all
end

ここまでCSSを適用していないためサンプルの見た目にはなっていない。
最後にCSSを適用させて終わる。

CSS

touch app/assets/stylesheets/category.scss

空のscssファイルを作成。
中身を書いていく。

app/assets/styleseets/category.scss
#tree_menu {
  width: 200px;
  position: absolute;
  z-index: 10;
  display: flex;

  .categoryTree {
    display: none;
    height: 100%;
    border: 1px solid #34aeb3;
    background-color: #ffffff;
    border-radius: 4px;
    box-shadow: 4px 4px 4px #999;

    a {
      padding: 0;

      input[type='button'] {
        min-width: 200px;
        height: 35px;
        font-size: 14px;
        text-decoration: none;
        color: #3ccace;
        background: #ffffff;
        outline: none;
        transition: 0.4s;
        border: #ffffff;
        padding: 0 0.9em;
        cursor: pointer;
      }

      input[type='button']:hover {
        background: #62d4d7;
        color: white;
      }

      input[type='button']:active {
        -webkit-transform: translateY(2px);
        transform: translateY(2px);
        box-shadow: 0 0 1px rgba(0, 0, 0, 0.15);
        background-image: linear-gradient(#b1e9eb 0%, #30a1a4 100%);
      }
    }
  }

  .categoryTree-child {
    display: none;
    height: 100%;
    border: 1px solid #34aeb3;
    background-color: #ffffff;
    border-radius: 4px;
    box-shadow: 4px 4px 4px #999;

    .category_child {
      a {
        padding: 0;

        input[type='button'] {
          min-width: 230px;
          height: 35px;
          font-size: 14px;
          text-decoration: none;
          color: #3ccace;
          background: #ffffff;
          outline: none;
          transition: 0.4s;
          border: #ffffff;
          padding: 0 0.9em;
          cursor: pointer;
        }

        input[type='button']:hover {
          background: #62d4d7;
          color: white;
        }

        input[type='button']:active {
          -webkit-transform: translateY(2px);
          transform: translateY(2px);
          box-shadow: 0 0 1px rgba(0, 0, 0, 0.15);
          background-image: linear-gradient(#b1e9eb 0%, #30a1a4 100%);
        }
      }
    }
  }

  .categoryTree-grandchild {
    display: none;
    height: 100%;
    border: 1px solid #34aeb3;
    background-color: #ffffff;
    border-radius: 4px;
    box-shadow: 4px 4px 4px #999;

    a {
      padding: 0;

      input[type='button'] {
        min-width: 250px;
        height: 35px;
        font-size: 14px;
        text-decoration: none;
        color: #3ccace;
        background: #ffffff;
        outline: none;
        transition: 0.4s;
        border: #ffffff;
        padding: 0 0.9em;
        cursor: pointer;
      }

      input[type='button']:hover {
        border: 1px solid #62d4d7;
        background: #62d4d7;
        color: white;
      }

      input[type='button']:active {
        -webkit-transform: translateY(2px);
        transform: translateY(2px);
        box-shadow: 0 0 1px rgba(0, 0, 0, 0.15);
        background-image: linear-gradient(#b1e9eb 0%, #30a1a4 100%);
      }
    }
  }
}

詳細ページに参照したカテゴリーを表示

app/contorollers/posts_controller.rb
  def show
    @post = Post.find(params[:id])
    @category_id = @post.category_id
    @category_parent = Category.find(@category_id).parent.parent
    @category_child = Category.find(@category_id).parent
    @category_grandchild = Category.find(@category_id)
  end

@postは特定のpostを参照。
@category_idはpostに紐づくカテゴリーを参照。
@category_parentは紐づいたカテゴリーの親の親を参照。
@category_childは紐づいたカテゴリーの親を参照。
@category_grandchildは紐づいたカテゴリーを参照。

app/views/posts/show.html.erb
    <%=  "#{ @category_parent.name } > #{ @category_child.name } > #{ @category_grandchild.name }" %>

定義したインスタンス変数からカラムを指定して表示させる。

終わりに

まるまる参考にさせてもらっている状態なので参考にした記事以上には、とても説明できないのが本音です。

参考にさせて頂いた記事を確認して頂くと間違いないかと思います。

重複する記述もあるのでもっとコンパクトにできそうに思えますが力不足でリファクタリングまではできませんでした。
また説明ができないのも自分の理解が追いついていない結果かと思いますので、いずれわかりやすく説明できるように努めたいと思います。

参考もと

jQueryとancestryを使ったカテゴリー選択機能(Ajax)
【Rails】カテゴリ検索機能の実装で学んだこと

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?