Vuetify 2.0 で datatable を使う

# Vuetify 2.0 で datatable を使って一覧画面を作る

Vuetify 2.0の **v-datatable** を用いて以下の方針で一覧画面を作ってみます。

* **並べ変え**に対応させる (第2ソート、第3ソートにも対応)
* **絞り込み**に対応させる
* 極力**日本語化**
* ページ切り替えの度にAjaxでレコードを取得

# 画面イメージ

### PCで見た場合
![PC.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/416295/03139fae-17f9-f151-2e01-c6170cea460e.png)

### スマホで見た場合 (表形式ではなく縦長のレイアウトになる！)
![SP.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/416295/b976b22d-34b1-5a40-9246-c2553a273597.png)

# まずは全体のソース

```vuejs
<template>
  <div id="form">
    <v-container fluid>
      <v-row justify="center">
        <v-col>
          <v-card>
            <v-app-bar color="primary" dark>
              <v-toolbar-title><v-icon>list_alt</v-icon> ○○一覧</v-toolbar-title>
              <v-tooltip bottom>
                <template v-slot:activator="{ on }">
                  <v-btn color="pink" dark small absolute bottom right fab to="/create" v-on="on"><v-icon>add</v-icon></v-btn>
                </template>
                <span>新しい○○を作成する</span>
              </v-tooltip>
            </v-app-bar>
            <v-form v-model="valid" ref="listForm" lazy-validation>
              <v-container fluid class="pa-1">
                <v-row class="px-2">
                  <v-col cols="6" class="pa-1">
                    <v-text-field
                      prepend-inner-icon="search"
                      clearable
                      label="タイトル"
                      name="title"
                      maxlength="64"
                      v-model="model.title"
                      @change="loadList"
                    ></v-text-field>
                  </v-col>
                  <v-col cols="3" class="pa-1">
                    <v-select
                      label="種別"
                      name="type"
                      item-text="label"
                      item-value="value"
                      :items="[
                        { label: '-', value: null },
                        { label: '銀行', value: 0 },
                        { label: '郵便局', value: 1 },
                      ]"
                      v-model="model.type"
                      @change="loadList"
                    ></v-select>
                  </v-col>
                  <v-col cols="3" class="pa-1">
                    <v-select
                      label="公開"
                      name="is_open"
                      item-text="label"
                      item-value="value"
                      :items="[
                        { label: '-', value: null },
                        { label: '公開中', value: true },
                        { label: '未公開', value: false },
                      ]"
                      v-model="model.is_open"
                      @change="loadList"
                    ></v-select>
                  </v-col>
                </v-row>
              </v-container>
              <v-data-table
                :headers="headers"
                :items="items"
                :options.sync="options"
                :server-items-length="total"
                :footer-props="{
                  'items-per-page-options': [10, 20, 50, 100, 200, 300, 400, 500],
                  showFirstLastPage: true,
                }"
                :loading="loading"
                multi-sort
                locale="ja-jp"
                loading-text="読込中"
                no-data-text="データがありません。"
                class="elevation-1"
              >
                <template v-slot:item.uri="{ item }">
                  <a
                    :href="'https://hoge.jp/' + item.uri"
                    target="_blank"
                    >hoge.jp/{{ item.uri }}</a
                  >
                </template>
                <template v-slot:item.type="{ item }">
                  {{ selectionItems.type[item.type] }}
                </template>
                <template v-slot:item.is_open="{ item }">
                  <v-icon v-if="item.is_open">check</v-icon>
                </template>
                <template v-slot:item.created_at="{ item }">
                  {{ item.created_at.replace('T', ' ').replace(/-/g, '/') }}
                </template>
                <template v-slot:item.updated_at="{ item }">
                  {{ item.updated_at.replace('T', ' ').replace(/-/g, '/') }}
                </template>
                <template v-slot:item.code="{ item }">
                  {{ item.name }}
                </template>
                <template v-slot:item.action="{ item }">
                  <v-btn small class="mx-1" color="orange accent-4" :to="'/detail/' + item.uri">
                    <v-icon>pageview</v-icon>詳細
                  </v-btn>
                  <v-btn small class="mx-1" color="orange accent-4" :to="'/stats/' + item.uri">
                    <v-icon>bar_chart</v-icon>集計
                  </v-btn>
                </template>
              </v-data-table>
            </v-form>
          </v-card>
        </v-col>
      </v-row>
    </v-container>
  </div>
</template>

<script>
import Axios from 'axios'
export default {
  data: () => ({
    loading: false,
    headers: [
      { text: 'ID', align: 'center', sortable: true, value: 'form_id' },
      { text: 'タイトル', align: 'center', sortable: true, value: 'title' },
      { text: 'URL', align: 'center', sortable: false, value: 'uri' },
      { text: '種別', align: 'center', sortable: false, value: 'form_type' },
      { text: '公開', align: 'center', sortable: false, value: 'is_open' },
      { text: '作成日時', align: 'center', sortable: true, value: 'created_at' },
      { text: '更新日時', align: 'center', sortable: true, value: 'updated_at' },
      { text: '組織', align: 'center', sortable: false, value: 'code' },
      { text: '操作', align: 'center', sortable: false, value: 'action' }
    ],
    options: {
      page: 1,
      itemsPerPage: 20,
      sortBy: ['form_id'],
      sortDesc: [true],
    },
    items: [],
    total: 0,
    selectionItems: {
      form_type: ['銀行', '郵便局'],
    },
    model: {
      'title': '',
      type: null,
      is_open: null,
    },
  }),
  watch: {
    options: {
      handler() {
        this.loadList()
      },
      deep: true,
    },
  },
  methods: {
    async loadList() {
      this.loading = true
      try {
        let sorts = []
        if (this.options.sortBy !== null) {
          this.options.sortBy.forEach((value, index) => {
            sorts.push((this.options.sortDesc[index] ? '-' : '+') + value)
          })
        }
        const res = await Axios.post(
          '/api/list',
          Object.assign(this.model, {
            offset: (this.options.page - 1) * this.options.itemsPerPage,
            limit: this.options.itemsPerPage,
            sort: sorts.join(' ')
          })
        )
        if (res.data) {
          this.items = res.data.items
          this.total = res.data.total
        }
      } catch (error) {
        alert('情報を取得できませんでした。時間をおいてやり直してください。')
      }
      this.loading = false
    },
  },
  created: function() {
    this.loadList()
  },
}
</script>

```

# Vuetify 1.5との違い

結構色々変わってて苦戦しました。

* `v-datatable` の項目名が色々変わっている
* `td`タグを書いていく方式から、各カラムの `template` タグを実装していく方式に変わった
* ソートカラムが文字列ではなく**配列**で返ってくる (multi-sortでない場合でも)

# Ajaxでのデータ取得
`datatable`は、

* 全レコードを取得して並べ替えや絞り込みをJS内で行う方式
* ページ切り替えやソート方法変更の度に該当ページのレコードを取得する方式

があります。レコードが数万レコードレベルで大量に存在する場合は前者の方法は使えません。
そこで後者の方式で実装します。

```vuejs
<v-data-table
  :headers="headers"
  :items="items"
  :server-items-length="total"
>
```

`:server-item-length` でレコードの総数を渡してあげると後者のモードになる模様。あとは、

```javascript
const res = await Axios.post(
  '/api/list',
  {
    offset: (this.options.page - 1) * this.options.itemsPerPage,
    limit: this.options.itemsPerPage
  }
)
if (res.data) {
  this.items = res.data.items
  this.total = res.data.total
}
```

で、現在のページ番号に応じたレコードを取得して変数に格納してあげるだけでいけました。

# ソート

```vuejs
<v-data-table
  multi-sort
>
```
第二ソート以上に対応させる場合は、`multi-sort`を指定し、対応させない場合は `multi-sort` を外して、必要に応じて `must-sort` を入れます。
`multi-sort` と `must-sort` を共存させてしまうと、ソートが外せなくなり、第一ソートと第二ソートの入れ替えなどができなくなってしまいます。

あとは、options.sortByにソート方法の配列 (例えば、`['title', 'id']`)、options.sortDescに昇順・降順の配列 (例えば、`[true, false]`)が格納されるので、APIにあった方法に変換してAjaxで渡します。
今回は、 `+title -id` といった形式で渡すようにしてみました。

```javascript
let sorts = []
if (this.options.sortBy !== null) {
  this.options.sortBy.forEach((value, index) => {
    sorts.push((this.options.sortDesc[index] ? '-' : '+') + value)
  })
}
sorts = sorts.join(' ')
```

# テーブルの描画
Ajaxから取得したデータをそのまま出力する場合は問題無いのですが、リンクやボタンをつけたり、複数の情報を組み合わせてデータを出力する場合などは、セルの描画内容をカスタマイズします。

```vuejs
<!-- リンクを出力する例 -->
<template v-slot:item.uri="{ item }">
  <a
    :href="'https://hoge.jp/' + item.uri"
    target="_blank"
    >hoge.jp/{{ item.uri }}</a
  >
</template>
<!-- カラム値を文字列に変換して出力する例 -->
<template v-slot:item.type="{ item }">
  {{ selectionItems.type[item.type] }}
</template>
<!-- チェックマークをつける例 -->
<template v-slot:item.is_open="{ item }">
  <v-icon v-if="item.is_open">check</v-icon>
</template>
<!-- ボタンを設置する例 -->
<template v-slot:item.action="{ item }">
  <v-btn small class="mx-1" color="orange accent-4" :to="'/detail/' + item.uri">
    <v-icon>pageview</v-icon>詳細
  </v-btn>
</template>
```

# 絞り込み
各絞り込みフィールドに `@change` 属性を追加し、発火したらAjaxでレコードを取得し直せば問題無いでしょう。

# 日本語化
Vuetifyの言語設定を `ja` に変えれば日本語になってくれました。 `Vuetify` を初期化している処理で、

```javascript
import ja from 'vuetify/es5/locale/ja.js'
new Vuetify({
  lang: {
    locales: {ja},
    current: 'ja'
  }
})
```
とすることで概ね日本語化されました。ただ、それでも日本語化されない箇所があったので別途 `datatable`側でも直に日本語設定を入れました。

```vuejs
<v-data-table
  loading-text="読込中"
  no-data-text="データがありません。"
>
```