Laravel + Vue.js + Vuetifyでページネーションを実現する

# バージョン
Laravel : 5.8
Vue.js : 2.5
Vuetify : 1.5

# 作る画面
サーバーサイドにLaravel、フロントエンドにVue.js、デザインにVuetifyを使用して検索つきのページネーションを作ります。
データは何でもいいので、本を検索することにしました。
このような画面が出来上がります。
![movie.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/323635/1e775b9b-b4e6-bb00-3d7a-580ff5bd1cb9.gif)



booksテーブルはtitle(タイトル)とpublishing_year(発行年)だけのシンプルなテーブルです。
データはLaravelのFakerとSeederで500件用意しました。
![list.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/323635/07834853-974e-e727-0f15-8da03b439a9e.png)



# Vuetifyとは
簡単に言うとCSSを使わずに、独自のHTMLタグでデザインを完成させてしまえる優れものです。
その他にもVue.jsで使えるマテリアルデザインコンポーネントはQuasarやBootstrapVueもあるみたいですが、何となくVuetifyが良さげな雰囲気を出しているので採用しました。
https://vuetifyjs.com/ja/

# インストール
まずはVue.jsとVuetifyを使う上で必要なライブラリをインストールします。
前述の通りVuetifyは1系を使います。

```
npm install vue-router vuex vuetify@1 css-loader material-design-icons-iconfont vuex-persistedstate
```

# ディレクトリ構成
主なファイルの配置です。
LaravelとVue.jsの一般的な構造なので特に問題ないと思います。

```
├── app
│   ├── Http
│   │   └── Controller 
│   │       └── BookController.php
│   └── Book.php
├── resources
│   └── js
│       ├── components
│       │   ├── Book.vue
│       │   ├── BookList.vue
│       │   └── SearchArea.vue
│       ├── app.js
│       ├── bootstrap.js
│       ├── router.js
│       ├── store.js
│       ├── util.js
│       └── App.vue
└── routes
    ├── api.php
    └── web.php
```

余談ですがresources/js/componentsにBook.vueとBookList.vueがあるのが腑に落ちない方はAtomic Designがピッタリです。
https://uxdaystokyo.com/articles/glossary/atomic-design/

# ルーティング
ここからはコードをどんどん載せていきます。

```routes/web.php
<?php

Route::get('/{any?}', function () {
    return view('index');
})->where('any', '.+');
```
画面の変化はJavaScriptで行うことになるので、どのURLでも```index.blade.php```を呼びます。

```resources/views/index.blade.php
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>vuetify pagination</title>
  <script src="{{ mix('js/app.js') }}" defer></script>
</head>
<body>
  <div id="app"></div>
</body>
</html>
```
Laravel Mixでコンパイルしたapp.jsを読み込みます。
VuetifyがあるのでCSSは使いません。

```webpack.mix.js
const mix = require("laravel-mix");

mix.js("resources/js/app.js", "public/js")
    .version();
```
webpack.mix.jsはこれだけです。
ここでもCSSは不要です。


```routes/api.php
<?php

Route::get('/books', 'BookController@index')->name('books');
```
apiのルーティングは本を検索するためのものです。

# JavaScript

```resources/js/app.js
import "./bootstrap";
import Vue from "vue";

import Vuetify from "vuetify";
Vue.use(Vuetify);

import "vuetify/dist/vuetify.min.css";
import "material-design-icons-iconfont/dist/material-design-icons.css";

import router from "./router";
import store from "./store";
import App from "./App.vue";

new Vue({
    el: "#app",
    router,
    store,
    components: { App },
    template: "<App />"
});
```

app.jsにVuetifyを使用するための記載をします。

```resources/js/router.js
import Vue from "vue";
import VueRouter from "vue-router";

import BookList from "./components/BookList.vue";

Vue.use(VueRouter);

const routes = [
    {
        path: "/",
        component: BookList
    }
];

const router = new VueRouter({
    mode: "history",
    routes
});

export default router;
```
router.jsでルートパスにアクセスしたらBookList.vueが呼ばれるように設定します。


```resources/js/store.js
import Vue from "vue";
import Vuex from "vuex";
import createPersistedState from "vuex-persistedstate";

Vue.use(Vuex);

const store = new Vuex.Store({
    plugins: [createPersistedState()]
});

export default store;
```
store.jsのvuex-persistedstateはブラウザをリロードしてもVuexのストアを保持してくれるもので、ログイン認証の永続化でよく使われるみたいです。
今回は特に使いませんが便利なので載せときましょう。

```resources/js/bootstrap.js
import { getCookieValue } from "./util";

window.axios = require("axios");

// Ajaxリクエストであることを示すヘッダーを付与する
window.axios.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest";

window.axios.interceptors.request.use(config => {
    // クッキーからトークンを取り出してヘッダーに添付する
    config.headers["X-XSRF-TOKEN"] = getCookieValue("XSRF-TOKEN");

    return config;
});

window.axios.interceptors.response.use(
    response => response,
    error => error.response || error
);
```

```resources/js/util.js
/**
 * クッキーの値を取得する
 * @param {String} searchKey 検索するキー
 * @returns {String} キーに対応する値
 */
export function getCookieValue(searchKey) {
    if (typeof searchKey === "undefined") {
        return "";
    }

    let val = "";

    document.cookie.split(";").forEach(cookie => {
        const [key, value] = cookie.split("=");
        if (key === searchKey) {
            return (val = value);
        }
    });

    return val;
}
```
bootstrap.jsはとutil.jsはCSRF対策のためのトークンをクッキーから取り出して、リクエストに含める処理です。
こちらのサイトを参考にさせてもらっています。
https://www.hypertextcandy.com/vue-laravel-tutorial-authentication-part-3/
素晴らしいサイトでかなりお世話になりました！

# Vue

```resources/js/App.vue
<template>
  <v-app>
    <v-content tag="div">
      <v-container fluid>
        <RouterView />
      </v-container>
    </v-content>
  </v-app>
</template>
```

やっとVuetifyのタグが登場しました。
```<v-app>```で囲む必要があります。
公式を見れば便利なタグがたくさんあるので、こちらを参考に。
https://vuetifyjs.com/ja/components/paginations

```resources/js/components/BookList.Vue
<template>
  <div>
    <search-area @search="searchBooks($event)"></search-area>
    <v-layout wrap>
      <v-flex sm4 pa-2>{{from}}〜{{to}}件 / {{total}}件</v-flex>
      <v-flex sm8>
        <v-layout justify-end>
          <v-pagination v-model="page" :length="length" :total-visible="10"></v-pagination>
        </v-layout>
      </v-flex>
    </v-layout>
    <v-layout wrap>
      <v-flex sm6 pa-2 v-for="(book, key, index) in books" :key="index">
        <book :title="book.title" :publishingYear="book.publishing_year"></book>
      </v-flex>
    </v-layout>
  </div>
</template>

<script>
// Book.vueとSearchArea.vue(検索エリア)を読み込みます。
import Book from "../components/Book.vue";
import SearchArea from "../components/SearchArea.vue";
export default {
  data() {
    return {
      books: [],    // 一覧データ
      page: 1,      // 表示中のページ(v-paginationにバインド)
      length: 0,    // ページネーションのリンクの数(v-paginationのprops)
      urlParams: "",// 検索パラメータ
      from: "",     // 一覧の開始
      to: "",       // 一覧の終了
      total: ""     // 件数
    };
  },
  methods: {
    // 検索ボタンをクリックしたら呼ばれる
    async searchBooks(params) {
      // 検索パラメータをURLに付与してapiを叩く
      this.urlParams = params;
      let url = "/api/books?page=" + this.page + "&" + this.urlParams;
      const response = await axios.get(url);
      // 戻り値をデータに代入すれば表示が変わってくれます
      let books = response.data.data;
      this.books = books;
      this.length = response.data.last_page;
      this.from = response.data.from;
      this.to = response.data.to;
      this.total = response.data.total;
    }
  },
  mounted() {
    // 初期表示
    this.searchBooks(this.urlParams);
  },
  watch: {
    // ページネーションのリンクをクリックするとpageが変わる。
    // pageを監視して、変更されたらsearchBooksを実行
    page: function(newPage) {
      this.searchBooks(this.urlParams);
    }
  },
  components: {
    Book,
    SearchArea
  }
};
</script>
```

BookList.Vueが今回の肝になるファイルで、ページネーションの処理はここで扱います。
pageというVue.jsで保持しているデータがトリガーになっていて、pageが変更されたら本を検索するapiが発火して、booksというデータが変更され、画面の表示が変わるという流れです。
通常のマルチページアプリケーションではページネーションのリンクのURLに直接遷移することが多いので、一番の違いはここではないでしょうか。

```resources/js/components/Book.Vue
<template>
  <v-card class="indigo lighten-5">
    <v-card-title class="pa-1">タイトル:{{title}}</v-card-title>
    <v-card-text class="pa-1">発行年:{{publishingYear}}</v-card-text>
  </v-card>
</template>

<script>
export default {
  props: {
    title: {
      type: String,
      required: true
    },
    publishingYear: {
      type: Number,
      required: true
    }
  }
};
</script>
```

Book.vueはpropsにタイトルの発行年があるだけで簡単です。
```<v-card>```のclassは色をつけるためのもで、Vuetifyが提供してくれます。
たくさんあるので、これだけあれば困ることはないでしょう。
https://vuetifyjs.com/ja/styles/colors

```resources/js/components/SearchArea.Vue
<template>
  <div>
    <v-layout wrap>
      <v-flex sm4 pa-2>
        <v-text-field v-model="searchForm.title" label="タイトル"></v-text-field>
      </v-flex>
      <v-flex sm4 pa-2>
        <v-text-field v-model="searchForm.publishing_year" label="発行年"></v-text-field>
      </v-flex>
      <v-flex pa-2>
        <v-btn @click="clickHandler">検索</v-btn>
      </v-flex>
    </v-layout>
  </div>
</template>

<script>
const querystring = require("querystring");
export default {
  data() {
    return {
      searchForm: {
        title: "",
        publishing_year: ""
      }
    };
  },
  methods: {
    clickHandler() {
      let params = querystring.encode(this.searchForm);
      this.$emit("search", params);
    }
  }
};
</script>
```
イベント名をsearch、引数をparams(検索パラメータ)にして検索ボタンにクリックイベントを定義しています。
paramsは検索項目に入力されている値をquerystringで文字列に変換してます。
```title=A&publishing_year=2018```みたいな文字列になります。

# PHP
Laravel側で本を検索する処理を作ります。

```app/Book.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Book extends Model
{
}
```

Modelはあれば良いので中身は空です。

```app/Http/Controllers/BookController.php
<?php

namespace App\Http\Controllers;

use App\Book;
use Illuminate\Http\Request;

class BookController extends Controller
{
    public function index(Request $request)
    {
        $per_page = 5; // 1ページあたりの件数
        $input = $request->all();
        $books = Book::select('id', 'title', 'publishing_year');
        if (!empty($input['publishing_year'])) {
            $books = $books->where('publishing_year', $input['publishing_year']);
        }
        if (!empty($input['title'])) {
            $books = $books->where('title', 'LIKE', "%{$input['title']}%");
        }
        $books = $books->paginate($per_page);

        return response()->json($books);
    }
}
```
検索してます。それだけですw

一応これで完成です！めでたし！！