4
3

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 * Vue.js * Ajaxで多階層カテゴリを作った話

Last updated at Posted at 2020-08-24

やったこと

Rails * Vue.js でアプリを作っています。
既存のRailsファイルに単一ファイルコンポーネントとしてVue.jsを組み込んでいくスタイルです。

親カテゴリー > 子カテゴリー > 孫カテゴリー

というふうに、多階層になっているカテゴリーのUIを作りました。イメージとしては、別サイトからの引用ですが、こちらの画像のようなものです。
category.gif

その時のコードを共有します。

前提条件

アプリの前提条件は次の通りです。

  • Rails 5.2.4.2
  • Vue.js 3.0.1

Rails * Vue.js * 単一ファイルコンポーネントの組み合わせで、Vue.jsを動かす方法はこちらをご覧ください。(こちらはいつかきちんとQiita記事にしたいです。。。)
Rails, Vue.js で単一ファイルコンポーネントを使用したいときの設定ファイルの書き方

また、ajax(axios)を使った、RailsとVueのデータのやり取りの方法はこちらをご覧ください。
VueのコンポーネントにRailsのデータを使った話(SFC利用、Railsの一部にVue使用

最後に、多階層カテゴリーの作成にあたってはancestoryというgemを利用しています。ancestoryの使い方はこちらをご覧ください。
多階層カテゴリでancestryを使ったら便利すぎた

作成したコード

テンプレート

今回作成したコードはこちらです。まずはテンプレートから。
なお、わかりやすさのため、Railsにデータを送るために必要なname属性や、Railsによる自動生成のidの記載は省いています。

javascript/components/select_form.vue
<template>
  <div class="select-form">
   <!-------- 親カテゴリー ------------->
    <div>
      <label>カテゴリー</label>
      <select @change="findChildren">
        <option v-for="root in roots" :value="root.id" :key="root.id">{{ root.name }}</option>
      </select>
    </div>

    <!-------- 子カテゴリー ------------->
    <div>
      <select @change="findGrandChildren">
        <option v-for="child in children" :value="child.id" :key="child.id">{{ child.name }}</option>
      </select>
    </div>
   
    <!-------- 孫カテゴリー ------------->
    <div>
      <select>
        <option v-for="gChild in grandChildren" :value="gChild.id" :key="gChild.id">{{ gChild.name }}</option>
      </select>
    </div>
  </div>
</template>

<script>
  // ajax通信を行うためにaxiosを読み込む
  import axios from 'axios';

  export default {
    data: function() {
      return {
        roots: [],
        children: [],
        grandChildren: [],
        root_id: "",
        child_id: ""
      }
    },
    mounted() {
      // DOM要素読み込み時に、それぞれのselectのオプションに初期値を入れる
      // response.data.roots などには、controllerで定義しjbuilderで生成した値が入る
      axios.get('/api/somethings/new.json').then(response => (this.roots = response.data.roots,
                                                              this.children = response.data.children,
                                                              this.grandChildren = response.data.grandChildren))
    },
    methods: {
      findChildren: function(event) {
        // selectボックスの値が変化したときに、optionのvalue値を取得する。
        // そしてその値をroot_idに返す。findGrandChildrenも同じ
        let rootValue = event.target.value;
        return this.root_id = rootValue;
      },
      findGrandChildren: function(event) {
        let childValue = event.target.value;
        return this.child_id = childValue;
      }
    },
    watch: {
      // root_id, child_idの値が変化したときに、新たなリクエストを生成する
      root_id: function() {
        if (this.root_id !== "" ) {
          //paramsでroot_idをcontrollerに渡す。
          axios.get('/api/somethings/new.json', { params: { root_id: this.root_id } }).then(response => (this.children = response.data.children))
        }
      },
      child_id: function() {
        if (this.child_id !== "" ) {
          //孫カテゴリー生成時には、root_id, child_idをcontrollerに渡す
          axios.get('/api/somethings/new.json', { params: { root_id: this.root_id, child_id: this.child_id } }).then(response => (this.grandChildren = response.data.grandChildren))
        }
      }
    }
  }
</script>

controller

コントローラーの定義は下記の通りです。実装にあたっては、こちらの記事を参考にしました。
[Rails] Ajax通信を用いたカテゴリボックス作成

なぜ/api/を挟んだディレクトリ構造になっているかは、先に挙げたこちらの記事をご参照ください。

controllers/api/somethings_controller.rb
class Api::SomethingsController < ApplicationController
  def new
    # 親カテゴリーの生成
    @roots = Category.roots
    root_id = params[:root_id]
    child_id = params[:child_id]
    
    # paramsにroot_idがあれば子カテゴリーを生成、なければ空の配列を返す。子と孫も同様。
    @children = root_id ? @roots.find(root_id).children : []
    @grand_children = child_id ? @children.find(child_id).children : []

    render 'new', formats: 'json', handlers: 'jbuilder'
  end
end

jbuilder

上記のcontrollerで定義したデータを用いて、jbuilderでテンプレートで使用するjsonの値を生成します。

views/api/somethings/new.json.jbuilder
json.roots @roots, :id, :name
json.children @children, :id, :name
json.grandChildren @grand_children, :id, :name

# こんなJSONデータが出力されます。
# => {"roots":[{"id":1,"name":"カテゴリー1"},{"id":2,"name":"カテゴリー2"},{"id":3,"name":"カテゴリー3"}],"children":[],"grandChildren":[]}

Jbulderの書き方は、いつもこちらの記事を参考にさせていただいています。
Railsのjbuilderの書き方と便利なイディオムやメソッド

"children":[]"grandChildren":[]は初期値では空ですが、親カテゴリーや子カテゴリーを選択すると、root_idchild_idparamsで渡され、それによって、

controller(再掲)
  root_id = params[:root_id]
  @children = root_id ? @roots.find(root_id).children : []

こんな感じに、@children@roots.find(root_id).children親カテゴリーの子カテゴリーたちが持って来れます。

上記の内容で、無事多階層カテゴリーが実装できました^^

その他

実際には、親カテゴリーを選択した後に子カテゴリーのselect_boxが現れる仕組みになっているので、まだまだ実装は続きますが、少し複雑なUIも実装できるようになってよかったなーと思いました。

追記:リファクタリング

その後、通っていたスクールの同窓生コミュニティでこの記事をシェアしたところ、「選択の度にリクエストを飛ばすような実装よりも、全データを一気に持ってきて、フィルターで処理するのが良い」とアドバイスを受けたので、直してみました。

controllers/api/somethings_controller.rb
class Api::SomethingsController < ApplicationController
  def new
    @categories = Category.all
    render 'new', formats: 'json', handlers: 'jbuilder'
  end
end
views/api/somethings/new.json.jbuilder
json.categories @categories, :id, :name

テンプレートの記述はかなり書き直しました。ただし、前提として
ただし、今回扱っているデータ(categories)は、以下の構造を持っています。

カテゴリー構造
#親カテゴリー
id が 100, 200, 300, ... 900

# 子カテゴリー
親カテゴリーのidが100の時、子カテゴリーのidは
110, 120, 130, ... 190

# 孫カテゴリー
親カテゴリーのidが110の時、その子カテゴリー(rootから見ると孫カテゴリー)のidは
111,112, 113, ... 119

その上で、書き直したテンプレートがこちらです。

javascript/components/select_form.vue
<template>
  <!-- テンプレート部は上記と同じなので略 -->
</template>

<script>
  import axios from 'axios';

  export default {
    data: function() {
      return {
        categories: [], //追記
        roots: [],
        children: [],
        grandChildren: [],
        root_id: "",
        child_id: ""
      }
    },
    mounted() {
      // DOM要素読み込み時に、全てのカテゴリーを取得し、rootsに初期値を入れる
      axios.get('/api/somethings/new.json').then(response => {let categories = response.data.categories,
                                                                  // idが100で割り切れるカテゴリーをrootと判断
                                                                  roots = categories.filter( (cat) => cat.id % 100 === 0 )
                                                              // dataのcategoriesとrootsに初期値を代入
                                                              this.categories = categories,
                                                              this.roots = roots
                                                            })
    },
    methods: {
      // 上記と同じなので略
    },
    watch: {
      // root_id, child_idの値が変化したときに、続くメソッドを実行する
      root_id: function() {
        if (this.root_id) {
          let rootId = Number(this.root_id),
              nextRootId = rootId + 100,
              // rootIdとnextRootIdの間にidがあり、かつidが10で割り切れるカテゴリーを
              // 親カテゴリーで選択された要素の子カテゴリーとする
              children = this.categories.filter( (value) => (rootId < value.id && value.id < nextRootId && value.id % 10 === 0) )

          return this.children = children
        }
      },
      child_id: function() {
        if (this.child_id) {
          let childId = Number(this.child_id),
              nextChildId = childId + 10,
              grandChildren = this.categories.filter( (value) => (childId < value.id && value.id < nextChildId) )
          return this.grandChildren = grandChildren
        }
      }
    }
  }
</script>

...リクエストの回数は初めの一回だけになりましたが、これで良いのかな:sweat_smile:
正しいJSの書き方は、社内にほとんど知見がないので、スクールでも通おうかな、と思った時間となりました。

とりあえず、実装できてよかったです!!

追記:さらにリファクタリング

こちらの記事にて、さらにこんな書き方もあるよ!という見本を見せていただいたので、参考にしながらリファクタリングしました。
[nuxt] [rails] filterメソッドで多階層のセレクトボックスを非同期で実現する

最終的なコードはこちらです。

javascript/components/select_form.vue
<template>
  <div>
  <!-------- 親カテゴリー ------------->
    <div>
      <label>カテゴリー</label>
      <select v-model="rootSelected">
        <option value="">大カテゴリー</option>
        <option
          v-for="root in roots"
          :value="root.id"
          :key="root.id">{{ root.name }}
          </option>
      </select>
    </div>

    <!-------- 子カテゴリー ------------->
    <div>
      <select v-model="childSelected">
        <option value="">中カテゴリー</option>
        <option
          v-for="child in children"
          :value="child.id"
          :key="child.id">{{ child.name }}
        </option>
      </select>
    </div>

    <!-------- 孫カテゴリー ------------->
    <div>
      <select v-model="grandChildSelected">

        <option value="">小カテゴリー</option>
        <option
          v-for="gChild in grandChildren"
          :value="gChild.id"
          :key="gChild.id">{{ gChild.name }}
        </option>
      </select>
    </div>

  </div>
</template>

<script>
  import axios from 'axios';

  export default {
    data: () => {
      return {
        categories: [],
        childSelected: '',
        grandChildSelected: '',
        rootSelected: '',
      }
    },
    mounted() {
      axios.get('/api/somethings/new.json').then(response => {this.categories = response.data.categories})
    },
    computed: {
      roots: function() {
        return this.categories.filter((category) => category.id % 100 === 0)
      },
      children: {
        get: function() {
        if (this.rootSelected === '') {
          return []
        }

        let rootId = Number(this.rootSelected),
            nextRootId = rootId + 100
                                                          // 親カテゴリーよりidが大きくて
        return this.categories.filter( (value) => (rootId < value.id &&
                                                           // 次のrootIdより小さく
                                                           value.id < nextRootId &&
                                                           // 10で割り切れる数
                                                           value.id % 10 === 0) )
        },
        set: function() {
            return this.children = children
        }
      },
      grandChildren: {
        get: function(){
          if (this.childrenSelected === '') {
            return []
          }
          let childId = Number(this.childSelected),
              nextChildId = childId + 10

          return this.categories.filter( (value) => (childId < value.id && value.code < nextChildId) )
          },
        set: function(value) {
          return this.grandChildren = grandChildren
        }
      }
    }
  }
</script>

人のコードを見るのは勉強になりますね:relaxed:これからも良いコードが書けるよう、研鑽を積んでいきます!

4
3
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
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?