Laravel/Vue.jsで多階層カテゴリー選択を実装してみた

2021.01.18

見出し画像

今回は動的な多階層のカテゴリー選択機能を実装しました。Vuexも使用しています。

itemとcategoryテーブルを作成し、hasOneアソシエーションで紐付けて保存されるようにします。categoryテーブルに保存される内容はvuexに置いてある配列の中から取得するidとカテゴリー名になります。

あんまりlaravelでの多階層カテゴリーについての記事がなかったので書くことにしました。そんな記事を探している方の参考になれば嬉しいです。バージョンは7.x系です。

目次

  1. 大まかな流れ
  2. モデルとマイグレーションファイルの作成
  3. コントローラーとルーティング設定
  4. Vuexの導入
  5. Vueコンポーネント作成(新規登録)
  6. コントローラー作成(新規登録)
  7. Vueコンポーネント作成(詳細)
  8. Vueコンポーネント作成(更新)
  9. コントローラー作成(更新)
  10. 学んだこと

すべて表示

大まかな流れ

モデルとマイグレーションファイルの作成。

コントローラーとルーティング設定

Vuexの導入

Vueコンポーネント作成(新規登録)

コントローラー作成(新規登録)

Vueコンポーネント作成(詳細)

Vueコンポーネント作成(更新)

コントローラー作成(更新)

っていう感じで実装していきます。

モデルとマイグレーションファイルの作成

今回はカテゴリーテーブルを作成してhasoneでアソシエーションをつけてます。

まずはモデルとマイグレーションファイルの作成に取り組みます。

php artisan make:model Item -m
php artisan make:model Category -m

マイグレーションファイルの書き換え

Item
   public function up()
   {
       Schema::create('items', function (Blueprint $table) {
           $table->id();
           $table->string('name');
           $table->integer('price');
           $table->foreignId('user_id')->constrained()->cascadeOnDelete();
           $table->timestamps();
       });
   }
   
Category
   public function up()
   {
       Schema::create('categories', function (Blueprint $table) {
           $table->id();
           $table->foreignId('item_id')->constrained()->cascadeOnDelete();
           $table->integer('category_id');
           $table->string('category_name');
           $table->timestamps();
       });
   }

次はモデルファイルを。

Item
class Item extends Model
{
   protected $fillable = ['name', 'price', 'user_id'];

   public function category()
   {
       return $this->hasOne('App\Category');

   }

   public function user()
   {
       return $this->belongsTo('App\User');
   }
}


Category
class Category extends Model
{
   protected $fillable = ['item_id', 'category_id', 'category_name'];

   public function item() 
   {
       return $this->belongsTo('App\Item');
   }
}

ここまで来たらマイグレートを実行します。

php artisan migrate
エラーが出たら下記のコマンド
php artisan migrate:fresh

コントローラーとルーティング設定

コントローラーの作成です。一緒にルーティングも設定します。

php artisan make:controller ItemController
Route::get('/item/new', 'ItemController@new')->name('item_new');
Route::post('/item/create', 'ItemController@create')->name('item_create');
Route::get('/item/{item}/show', 'ItemController@show')->name('item_show');
Route::get('/item/{item}/edit', 'ItemController@edit')->name('item_edit');
Route::post('/item/{item}/update', 'ItemController@update')->name('item_update');
Route::post('/item/{item}/destroy', 'ItemController@destroy')->name('item_destroy');

Route::resourceでまとめていいと思いますw

Vuexの導入

こちらの記事を参考に導入しました。

store/index.jsについては毎回載せると量が多くなるのでここにまとめて全て貼ります。そのつど戻ってどのアクションが動いてるのか確認することをお勧めします。

/store/index.js

import Vue from 'vue';
import Vuex from 'vuex';
import { concat } from 'lodash';

Vue.use(Vuex);

export default new Vuex.Store({
 state: {
   category_show: {
     parent: '',
     child: '',
     grandchild: ''
   },
   children: [],
   grandchildren: [],
   here: '',
   lifes: [
     {id: 1, name: '運動'}, 
     {id: 2, name: '食べ物'},
     {id: 3, name: '学び'},
   ],
   childsbeta: [
     {id: 4, parent_id: 1, name: '海のスポーツ'}, 
     {id: 5, parent_id: 1, name: '冬のスポーツ'},
     {id: 6, parent_id: 1, name: '夏のスポーツ'},
     {id: 7, parent_id: 2, name: '和食'}, 
     {id: 8, parent_id: 2, name: '洋食'},
     {id: 9, parent_id: 2, name: 'インスタント'},
     {id: 10, parent_id: 3, name: '理数系'}, 
     {id: 11, parent_id: 3, name: '文系'},
     {id: 12, parent_id: 3, name: 'プログラミング'},
   ],
   grandchildsbeta: [
     {id: 13, child_id: 4, name: 'サーフィン'}, 
     {id: 14, child_id: 4, name: 'サップ'},
     {id: 15, child_id: 4, name: 'ビーチバレー'},
     {id: 16, child_id: 5, name: 'スキー、スノボー'}, 
     {id: 17, child_id: 5, name: 'カーリング'},
     {id: 18, child_id: 5, name: 'スケート'},
     {id: 19, child_id: 6, name: '野球'}, 
     {id: 20, child_id: 6, name: 'サッカー'},
     {id: 21, child_id: 6, name: '陸上'},
     {id: 22, child_id: 7, name: '寿司'}, 
     {id: 23, child_id: 7, name: 'ラーメン'},
     {id: 24, child_id: 7, name: '鍋'},
     {id: 25, child_id: 8, name: 'ピザ'}, 
     {id: 26, child_id: 8, name: 'パスタ'},
     {id: 27, child_id: 8, name: 'ハンバーガー'},
     {id: 28, child_id: 9, name: 'マクドナルド'}, 
     {id: 29, child_id: 9, name: 'バーガーキング'},
     {id: 30, child_id: 9, name: '蒙古タンメン'},
     {id: 31, child_id: 10, name: '医学'}, 
     {id: 32, child_id: 10, name: '化学'},
     {id: 33, child_id: 10, name: '天文学'},
     {id: 34, child_id: 11, name: '古文'}, 
     {id: 35, child_id: 11, name: '歴史、地理'},
     {id: 36, child_id: 11, name: '心理学'},
     {id: 37, child_id: 12, name: 'go'}, 
     {id: 38, child_id: 12, name: 'html'},
     {id: 39, child_id: 12, name: 'javascript'},
   ],
 },
 mutations: {
   child(state, id) {
     state.children = state.childsbeta.filter(value => value.parent_id == id)
   },
   grangchild(state, id) {
     state.grandchildren = state.grandchildsbeta.filter(value => value.child_id == id)
   },
   here(state, id) {
     if(id <= 3) {
       state.here = state.lifes.find(value => value.id == id)
     } else if(id > 3 && id <= 12) {
       state.here = state.childsbeta.find(value => value.id == id)
     } else if(id > 12 && id <= 39) {
       state.here = state.grandchildsbeta.find(value => value.id == id)
     }
   },
   category(state, id) {
     if(id <= 3) {
       state.category_show.parent = state.lifes.find(value => value.id == id)
     } else if(id > 3 && id <= 12) {
       state.category_show.child = state.childsbeta.find(value => value.id == id)
       state.category_show.parent = state.lifes.find(value => value.id == state.category_show.child.parent_id)
     } else if(id > 12 && id <= 39) {
       state.category_show.grandchild = state.grandchildsbeta.find(value => value.id == id)
       state.category_show.child = state.childsbeta.find(value => value.id == state.category_show.grandchild.child_id)
       state.category_show.parent = state.lifes.find(value => value.id == state.category_show.child.parent_id)
     }
   }
 },
 actions: {
   findchild({commit}, id) {
     commit('child', id)
   },
   findgrandchild({commit}, id) {
     commit('grangchild', id)
   },
   herecategory({commit}, id) {
     commit('here', id)
   },
   category_show({commit}, id) {
     commit('category', id)
   }
 },
 getters: {
   children(state) {
     return state.children
   },
   grandchildren(state) {
     return state.grandchildren
   },
   here(state) {
     return state.here
   },
   category(state) {
     return state.category_show
   }
 },
});

app.jsもついでに(追加した部分だけ)

import store from './store'


Vue.component('category-new', require('./components/CategoryNew.vue').default);
Vue.component('category-show', require('./components/CategoryShow.vue').default);
Vue.component('category-edit', require('./components/CategoryEdit.vue').default);

const app = new Vue({
   el: '#app',
   store, 追加
});

Vueコンポーネント作成(新規登録)

blade側にvueを貼って表示させます。itemのnameとprice部分については省略します。

<category-new></category-new> 表示したい位置に

流れとしてはカテゴリー選択部分をコンポーネント化。扱うデータとアクションをvuexで管理。vueからはvuexの呼び出しとbladeからのデータの受け取りくらいです。

<template>
 <div>
   <div>
     <label for="parent">カテゴリー</label>
     <select class="custom-select custom-select-sm" id="parent" v-model="select" @change="categorychild">
       <option disabled value="">選択してください</option>
       <option :value="life.id" v-for="life in lifes" :key="life.id">
         {{life.name}}
       </option>
     </select>
     <div v-if="children.length !== 0">
       <select class="custom-select custom-select-sm" id="child" v-model="selectchild" @change="categorygrandchild">
         <option disabled value="">選択してください</option>
         <option :value="child.id" v-for="child in children" :key="child.id">
           {{child.name}}
         </option>
       </select>
     </div>
     <div v-if="grandchildren.length !== 0">
       <select class="custom-select custom-select-sm" id="child" v-model="selectgrandchild" @change="sendnew">
         <option disabled value="">選択してください</option>
         <option :value="grandchild.id" v-for="grandchild in grandchildren" :key="grandchild.id">
           {{grandchild.name}}
         </option>
       </select>
     </div>
     <input type="hidden" name="category_id" :value="here.id">
     <input type="hidden" name="category_name" :value="here.name">
   </div>
 </div>
</template>

<script>

import {mapState, mapGetters} from 'vuex'

export default {
 data() {
   return {
     select: '',
     selectchild: '',
     selectgrandchild: '',
   }
 },
 computed: {
   ...mapState(['lifes']),
   ...mapGetters(['children', 'grandchildren', 'here'])
 },
 methods: {
   categorychild() {
     this.$store.dispatch('findchild', this.select)
     this.$store.dispatch('herecategory', this.select)
   },
   categorygrandchild() {
     this.$store.dispatch('findgrandchild', this.selectchild)
     this.$store.dispatch('herecategory', this.selectchild)
   },
   sendnew() {
     this.$store.dispatch('herecategory', this.selectgrandchild)
   }
 }
}
</script>

computedはlifesだけ最初に用意します。子と孫はgettersで取得します。

dataの中にidを作り、v-modelで双方向バインディングでvuexに繋げる時にidを渡しています。

herecategoryはindex.jsを確認して欲しいです。これは親でも子でも登録できるようにしています。

inputを設置することでblade側で送信されてもコントローラーに一緒に渡してくれます。

コントローラー作成(新規登録)

$item->category()->save($category)で紐づいた状態で保存出来ます。

use App\Item;
use App\Image;
use App\Category;
use Illuminate\Support\Facades\Auth;


   public function new()
   {
       return view('item.new');
   }

   public function create(Request $request)
   {
       // dd($request); で中身確認しましょう。
       $user = Auth::user();
       $item = new Item();
       $item->name = $request->name;
       $item->price = $request->price;
       $item->user_id = $user->id;
       $item->save();

       $category = new Category();
       $category->category_id = $request->category_id;
       $category->category_name = $request->category_name;
       $item->category()->save($category);

       return redirect()->route('home');
   }

Vueコンポーネント作成(詳細)

記載が少ないので、先にコントローラーを記述します。$itemの情報だけ送ってあげましょう。

   public function show(Item $item)
   {
       return view('item.show')->with('item', $item);
   }

blade側でcategroy_idだけ付与してあげます。

<category-show category_id="{{$item->category->category_id}}"></category-show>

propsでcategory_idを受け取りその情報を元にvuexで取得してくるようにしています。

if文を付けているのは子や孫まで登録されていないときに備えています。

<template>
 <div>
   <p>カテゴリー</p>
   {{ category.parent.name }}
   <br>
   <div v-if="category.child">
     {{ category.child.name }}
   </div>
   <div v-if="category.grandchild">
     {{ category.grandchild.name }}
   </div>
 </div>
</template>

<script>

import {mapGetters} from 'vuex'

export default {
 props: ['category_id'],
 computed: {
   ...mapGetters(['category'])
 },
 created() {
   this.$store.dispatch('category_show', this.category_id)
 }
}
</script>

vuex側ではmutationsのcategoryがメインで動いています。紐づくカテゴリーを全て取得してstate.categoryに保存しています。

Vueコンポーネント作成(更新)

コントローラーのeditアクションを先に。作成したユーザーであればitemの情報を送ってあげましょう。

   public function edit(Item $item) 
   {
       $user = Auth::user();
       if($user->id === $item->user_id) {
           return view('item.edit')->with('item', $item);
       } else {
           return back();
       }
   }

blade側は詳細の時とほとんど同じです。

<category-edit category_id="{{$item->category->category_id}}"></category-edit>
<template>
 <div>
   <label for="parent">カテゴリー</label>
   <select class="custom-select custom-select-sm" id="parent" v-model="select" @change="categorychild">
     <option disabled value="">{{category.parent.name}}</option>
     <option :value="life.id" v-for="life in lifes" :key="life.id">
       {{life.name}}
     </option>
   </select>
   <div v-if="children.length !== 0">
     <select class="custom-select custom-select-sm" id="child" v-model="selectchild" @change="categorygrandchild">
       <option disabled value="">{{first_child_name}}</option>
       <option :value="child.id" v-for="child in children" :key="child.id">
         {{child.name}}
       </option>
     </select>
   </div>
   <div v-if="grandchildren.length !== 0">
     <select class="custom-select custom-select-sm" id="child" v-model="selectgrandchild" @change="sendnew">
       <option disabled value="">{{first_grandchild_name}}</option>
       <option :value="grandchild.id" v-for="grandchild in grandchildren" :key="grandchild.id">
         {{grandchild.name}}
       </option>
     </select>
     <input type="hidden" name="category_id" :value="here.id">
     <input type="hidden" name="category_name" :value="here.name">
   </div>
 </div>
</template>

<script>
import {mapState, mapGetters} from 'vuex'
export default {
 props: ['category_id'],
 data() {
   return {
     first_child_name: '',
     first_grandchild_name: '',
     select: '',
     selectchild: '',
     selectgrandchild: '',
   }
 },
 computed: {
   ...mapState(['lifes']),
   ...mapGetters(['category', 'children', 'grandchildren', 'here'])
 },
 created() {
   this.$store.dispatch('category_show', this.category_id)
   this.checkcategory()
 },
 mounted() {
   this.$store.dispatch('findchild', this.category.parent.id)
   this.$store.dispatch('findgrandchild', this.category.child.id)
   this.$store.dispatch('herecategory', this.category.grandchild.id)
 },
 methods: {
   categorychild() {
     this.$store.dispatch('findchild', this.select)
     this.first_child_name = '選択してください'
     this.first_grandchild_name = '選択してください'
     this.$store.dispatch('herecategory', this.select)
   },
   categorygrandchild() {
     this.$store.dispatch('findgrandchild', this.selectchild)
     this.first_grandchild_name = '選択してください'
     this.$store.dispatch('herecategory', this.selectchild)
   },
   sendnew() {
     this.$store.dispatch('herecategory', this.selectgrandchild)
   },
   checkcategory() {
     if(this.category.child.name == null) {
       this.first_child_name = '選択してください'
     } else {
       this.first_child_name = this.category.child.name
     }
     if(this.category.grandchild.name == null) {
       this.first_grandchild_name = '選択してください'
     } else {
       this.first_grandchild_name = this.category.grandchild.name
     }
   }
 }
}
</script>

editは初回は保存されているデータを表示する、もし変更があれば動的に変更してく。という流れになるので少し記述量が増えます。

first_child_nameとfirst_grandchild_nameは初回は元々のデータを表示させるが、変更があった場合は’選択して下さい’を表示するようにしています。

ライフサイクルメソッドって便利だな〜と思った部分ですw

editも最終的にhereに値を入れてコントローラーに飛ばします。

コントローラー作成(更新)

$item->categoryで元々保存されているデータを取得します。そこに対して$requestで飛んできているデータを入れて保存する流れです。

dd($request);で確認しながら進むと良いかと。。

public function update(Request $request, Item $item) 
   {
       // dd($request);
       $item->name = $request->name;
       $item->price = $request->price;
       $item->save();

       $category = $item->category;
       $category->category_id = $request->category_id;
       $category->category_name = $request->category_name;
       $item->category()->save($category);
       return redirect()->route('item_show', ['item' => $item->id]);
   }

学んだこと

inputにnameをつければコンポーネント先からでもコントローラーに送信できる。これが1番の学びでした。axiosとか使う必要がないのが発見でした。

vuexは久しぶりに使用しましたが、便利だなと思います。ただ、vuexを呼び出す際にdispatchを大量に書いているのでその部分も省略していけたらなと。。

まとめ

コントローラーはitemの情報を持ってbladeに移動する。

bladeはカテゴリーidをコンポーネントに渡す。

vue側ではpropsでカテゴリーidを受け取りその情報を元にvuexを呼び出し値を取得、表示させる。

コントローラーに送るにはinputにname属性を付けると飛ばすことができる。つまりaxiosを使ってやり取りを行う必要がない。

ここら辺かなと思います。

もっとこうした方が良いとか、リファクタリングの事あれば教えていただきたいです。

今回はこんな感じで

以上!