Rails * Vue.js * Ajaxで多階層カテゴリを作った話

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

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

というふうに、多階層になっているカテゴリーのUIを作りました。イメージとしては、別サイトからの引用ですが、こちらの画像のようなものです。
![category.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393888/6893947c-4dd4-cdba-0133-38e7a0f38456.gif)

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

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

- `Rails 5.2.4.2`
- `Vue.js 3.0.1`

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

また、`ajax`(axios)を使った、RailsとVueのデータのやり取りの方法はこちらをご覧ください。
[VueのコンポーネントにRailsのデータを使った話（SFC利用、Railsの一部にVue使用](https://qiita.com/tanutanu/items/5935c8bd6489f7b3acaf)

最後に、多階層カテゴリーの作成にあたっては`ancestory`というgemを利用しています。`ancestory`の使い方はこちらをご覧ください。
[多階層カテゴリでancestryを使ったら便利すぎた](https://qiita.com/Sotq_17/items/120256209993fb05ebac)

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

```Vue: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通信を用いたカテゴリボックス作成](https://qiita.com/mihou/items/0fbda161527fcec48e81)

なぜ`/api/`を挟んだディレクトリ構造になっているかは、先に挙げた[こちら](https://qiita.com/tanutanu/items/5935c8bd6489f7b3acaf)の記事をご参照ください。

```ruby: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`の値を生成します。

```ruby: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の書き方と便利なイディオムやメソッド](https://qiita.com/ryouzi/items/06cb0d4aa7b6527b3645)

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

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

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

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

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

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

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

```ruby:views/api/somethings/new.json.jbuilder
json.categories @categories, :id, :name
```

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

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

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

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

```

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

```Vue: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メソッドで多階層のセレクトボックスを非同期で実現する](https://qiita.com/nasuka/items/803efb864ed4baa9167b)

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

```Vue: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:これからも良いコードが書けるよう、研鑽を積んでいきます！