Vuetifyのv-data-tableで階層構造のデータを検索できるようにする

最近はAngularからVue.jsに浮気中の8amjpです。Vue.jsめっちゃ楽しいです。

## v-data-tableコンポーネントを使う

さて、下記のようなデータがあるとします。
（ちなみにこのデータ、[Zabbix](https://www.zabbix.com/) APIで取得できるホスト一覧データから抜粋したものです）

```json
[
    {
        "hostid": "1",
        "host": "Server 1",
        "description": "サーバ1号機",
        "parentTemplates": [
            {
                "name": "Windows Servers",
                "templateid": "1"
            },
            {
                "name": "Web Servers",
                "templateid": "2"
            }
        ],
        "interfaces": [
            {
                "interfaceid": "1",
                "ip": "192.168.1.1",
                "port": "80",
            }
        ]
    },
    {
        ....
```

よくあるタイプのJSONデータですね。`parentTemplates`や`interfaces`といった項目が配列になっており、その中に複数の子オブジェクトが格納されている……という階層構造になっています。

さて、このデータを、[Vuetify](https://vuetifyjs.com/ja/)の[v-data-table](https://vuetifyjs.com/ja/components/data-tables/)コンポーネントを使って表示すると、こんな感じになると思います。

```vue
<template>
  <v-data-table :headers="headers" :items="items" :search="search">
    <template v-slot:top>
      <v-text-field v-model="search" append-icon="mdi-magnify" label="Search" single-line/>
    </template>
    <template v-slot:item.parentTemplates="{ item }">
      <div v-for="i in item.parentTemplates" :key="i.templateid">{{ i.name }}</div>
    </template>
    <template v-slot:item.interfaces="{ item }">
      <div v-for="i in item.interfaces" :key="i.interfaceid">{{ i.ip }}:{{ i.port }}</div>
    </template>
  </v-data-table>
</template>

<script>
export default {
  props: ['items'],
  data () {
    return {
      search: '',
      headers: [
        {
          text: 'ホスト名',
          value: 'host',
        },
        {
          text: '説明',
          value: 'description',
        },
        {
          text: 'テンプレート',
          value: 'parentTemplates',
        },
        {
          text: 'インターフェース',
          value: 'interfaces',
        },
      ]
    }
  },
}
</script>
```

階層構造のデータは、そのままだと表示できないので、`item.<name>`スロットを使って`v-for`ですべてのデータを表示したりします。便利ですねー。

## カスタムフィルタを使う

さて、ここから本題。

`v-data-table`は、データを簡単にフィルタリングするために`search`プロパティが用意されています。
バインドされたテキストフィールドに文字を入力すると、その文字列が含まれる行のみをフィルタリングできます。よくある機能ではありますが、最初から用意されていると実装もとても簡単。

ところが、です。前述のデータで言うと、`host`や`description`などの項目はフィルタリングできるのですが、`parentTemplates`や`interfaces`の項目に含まれるデータはフィルタリングができません。例えば、「Windows」というキーワードを入力してもヒットしません。
理由はもちろん、文字列じゃないから。さすがに配列の中身までは検索してくれません。
こういう時こそカスタムフィルタの出番です。

### 標準のフィルタを確認する

まずは、標準のフィルタがどういう動きをしているのか確認します。

```js
function defaultFilter(value, search, item) {
  return value != null &&
    search != null &&
    typeof value !== 'boolean' &&
    value.toString().toLocaleLowerCase().indexOf(search.toLocaleLowerCase()) !== -1;
}
```

この中の`value`という変数に、JSONデータの「値」の部分が入るんですけど。
値が配列の場合でも、うまい具合に文字列に変換して渡すことで、なんとかなりそうな感じがします。

では実際にやってみましょう。`v-data-table`の`custom-filter`プロパティに独自のメソッドを追加することで、動作を上書きすることができるんです。
まずは`custom-filter`プロパティを追加して、独自のメソッドを指定します。

```vue
  <v-data-table :headers="headers" :items="items" :search="search" :custom-filter="customFilter">
```

で、追加したメソッドで、フィルタリングの動作を上書きします。こんな感じで。

```js
  methods: {
    customFilter (value, search) {
      return value != null &&
        search != null &&
        typeof value !== 'boolean' &&
        // value.toString().toLocaleLowerCase().indexOf(search.toLocaleLowerCase()) !== -1;
        (typeof value === 'object' ? value.map(v => v.name).join('\t') : value)
          .toString().toLocaleLowerCase().indexOf(search.toLocaleLowerCase()) !== -1;
    },
  },
```

まず、変数`value`（=JSONデータの「値」）の型を`typeof`演算子で判定します。
で、`object`だった場合は配列とみなし、`.map`メソッドで配列内の`name`プロパティのみを抽出して、タブ文字で連結して単一の文字列にしています。
このように、単一の文字列にしてしまうことで、検索ができるようになります。
（当然ながら、`typeof`で`object`が返ってきても、配列じゃない場合もあるんですけど。その辺は使用するデータに応じて各自いい感じに処理を分岐してくださいね（丸投げ））

ついでに、`interfaces`配列内の`ip`プロパティも検索対象に含めたい場合は、こんな感じでどうでしょうか。

```js
        (typeof value === 'object' ? value.map(v => v.name || v.ip).join('\t') : value)
          .toString().toLocaleLowerCase().indexOf(search.toLocaleLowerCase()) !== -1;
```

## 完成

完成品はこちらです。わーい。

```vue
<template>
  <v-data-table :headers="headers" :items="items" :search="search" :custom-filter="customFilter">
    <template v-slot:top>
      <v-text-field v-model="search" append-icon="mdi-magnify" label="Search" single-line/>
    </template>
    <template v-slot:item.parentTemplates="{ item }">
      <div v-for="i in item.parentTemplates" :key="i.templateid">{{ i.name }}</div>
    </template>
    <template v-slot:item.interfaces="{ item }">
      <div v-for="i in item.interfaces" :key="i.interfaceid">{{ i.ip }}:{{ i.port }}</div>
    </template>
  </v-data-table>
</template>

<script>
export default {
  props: ['items'],
  data () {
    return {
      search: '',
      headers: [
        {
          text: 'ホスト名',
          value: 'host',
        },
        {
          text: '説明',
          value: 'description',
        },
        {
          text: 'テンプレート',
          value: 'parentTemplates',
        },
        {
          text: 'インターフェース',
          value: 'interfaces',
        },
      ]
    }
  },
  methods: {
    customFilter (value, search) {
      return value != null &&
        search != null &&
        typeof value !== 'boolean' &&
        (typeof value === 'object' ? value.map(v => v.name || v.ip).join('\t') : value)
          .toString().toLocaleLowerCase().indexOf(search.toLocaleLowerCase()) !== -1;
    },
  },
}
</script>
```