やったこと
Rails * Vue.js でアプリを作っています。
既存のRailsファイルに単一ファイルコンポーネントとしてVue.jsを組み込んでいくスタイルです。
親カテゴリー > 子カテゴリー > 孫カテゴリー
というふうに、多階層になっているカテゴリーのUIを作りました。イメージとしては、別サイトからの引用ですが、こちらの画像のようなものです。
その時のコードを共有します。
前提条件
アプリの前提条件は次の通りです。
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の記載は省いています。
<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/
を挟んだディレクトリ構造になっているかは、先に挙げたこちらの記事をご参照ください。
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
の値を生成します。
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_id
やchild_id
がparams
で渡され、それによって、
root_id = params[:root_id]
@children = root_id ? @roots.find(root_id).children : []
こんな感じに、@children
に@roots.find(root_id).children
親カテゴリーの子カテゴリーたちが持って来れます。
上記の内容で、無事多階層カテゴリーが実装できました^^
その他
実際には、親カテゴリーを選択した後に子カテゴリーのselect_box
が現れる仕組みになっているので、まだまだ実装は続きますが、少し複雑なUIも実装できるようになってよかったなーと思いました。
追記:リファクタリング
その後、通っていたスクールの同窓生コミュニティでこの記事をシェアしたところ、「選択の度にリクエストを飛ばすような実装よりも、全データを一気に持ってきて、フィルターで処理するのが良い」とアドバイスを受けたので、直してみました。
class Api::SomethingsController < ApplicationController
def new
@categories = Category.all
render 'new', formats: 'json', handlers: 'jbuilder'
end
end
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
その上で、書き直したテンプレートがこちらです。
<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>
...リクエストの回数は初めの一回だけになりましたが、これで良いのかな
正しいJSの書き方は、社内にほとんど知見がないので、スクールでも通おうかな、と思った時間となりました。
とりあえず、実装できてよかったです!!
追記:さらにリファクタリング
こちらの記事にて、さらにこんな書き方もあるよ!という見本を見せていただいたので、参考にしながらリファクタリングしました。
[nuxt] [rails] filterメソッドで多階層のセレクトボックスを非同期で実現する
最終的なコードはこちらです。
<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>
人のコードを見るのは勉強になりますねこれからも良いコードが書けるよう、研鑽を積んでいきます!