GraphQLにおけるRelayスタイルによるページング実装（後編：フロントエンド）

## お題
[前回](https://qiita.com/sky0621/items/1e8823200633f2c46013)は表題におけるバックエンド部分を実装し、GraphQLのプレイグラウンドでの実行上で動作確認をした。
今回は、フロントエンドでGraphQLクライアントを用いた実装を行う。
ページング機能含むテーブルを持つ画面のデザインは[Vuetify](https://vuetifyjs.com/ja/)の[Data tables](https://vuetifyjs.com/ja/components/data-tables)を採用。

## 機能
- Searchテキストボックスによる一覧表示レコードの絞り込み（部分一致）
- 「TODO」欄、「Done」欄、「CreatedAt」欄、「User」欄の昇降順ソート
- 一覧表示件数の切り替え（「5件」、「10件」、「15件」、「全件」）
- 前後ページへのページング

イメージとしては↓のような感じ。
◆１ページ目
![Screenshot from 2020-02-06 23-58-18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/13ba5bc0-b900-dc3e-c3b2-cddc1aa1a9f9.png)
◆２ページ目
![Screenshot from 2020-02-06 23-58-45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/3aafc0b0-4d2b-5fb0-ca90-8f09e62c99b6.png)


# 関連記事索引
- 第12回「[GraphQLにおけるRelayスタイルによるページング実装再考(Window関数使用版)](https://qiita.com/sky0621/items/957c010ba01c7a756ef7)」
- 第11回「[Dataloadersを使ったN+1問題への対応](https://qiita.com/sky0621/items/621f075e4257270a9e02)」
- 第10回「[GraphQL(gqlgen)エラーハンドリング](https://qiita.com/sky0621/items/affc833e770fee8b576e)」
- 第９回「[GraphQLにおける認証認可事例（Auth0 RBAC仕立て）](https://qiita.com/sky0621/items/efbb8fa085094230d0fd)」
- 第８回「[GraphQL/Nuxt.js(TypeScript/Vuetify/Apollo)/Golang(gqlgen)/Google Cloud Storageの組み合わせで動画ファイルアップロード実装例](https://qiita.com/sky0621/items/5c2c565678c102d9d4ed)」
- 第７回「[GraphQLにおけるRelayスタイルによるページング実装（後編：フロントエンド）](https://qiita.com/sky0621/items/fdcdaefeef7cf4b750ac)」
- 第６回「[GraphQLにおけるRelayスタイルによるページング実装（前編：バックエンド）](https://qiita.com/sky0621/items/1e8823200633f2c46013)」
- 第５回「[DB接続付きGraphQLサーバ(by Golang)をローカルマシン上でDockerコンテナ起動](https://qiita.com/sky0621/items/58fa2bcfc0935f6186cb)」
- 第４回「[graphql-codegenでフロントエンドをGraphQLスキーマファースト](https://qiita.com/sky0621/items/f03631f0309f5c375b1d)」
- 第３回「[go+gqlgenでGraphQLサーバを作る（GORM使ってDB接続）](https://qiita.com/sky0621/items/32e3e26189589080e492)」
- 第２回「[NuxtJS(with Apollo)のTypeScript対応](https://qiita.com/sky0621/items/0297f5a026be380a5929)」
- 第１回「[frontendに「nuxtjs/apollo」、backendに「go+gqlgen」の組み合わせでGraphQLサービスを作る](https://qiita.com/sky0621/items/8abd445edba347e8f6f1)」

# 開発環境
## # OS - Linux(Ubuntu)
```
$ cat /etc/os-release
NAME="Ubuntu"
VERSION="18.04.2 LTS (Bionic Beaver)"
```

## # フロントエンド
### Nuxt.js
```
$ cat yarn.lock | grep "@nuxt/vue-app"
    "@nuxt/vue-app" "2.11.0"
"@nuxt/vue-app@2.11.0":
  resolved "https://registry.yarnpkg.com/@nuxt/vue-app/-/vue-app-2.11.0.tgz#05aa5fd7cc69bcf6a763b89c51df3bd27b58869e"
```

### パッケージマネージャ - Yarn
```
$ yarn -v
1.19.2
```

### IDE - WebStorm
```
WebStorm 2019.3
Build #WS-193.5233.80, built on November 25, 2019
```

# 実践

### ■全ソース
https://github.com/sky0621/study-graphql/tree/v0.8.0

### ■プロジェクト構成


```bash:関係あるところだけ抜粋
$ pwd
/home/sky0621/src/github.com/sky0621/study-graphql/src/frontend
$
$ tree -L 3
.
├── apollo
│   └── queries
│       └── todoConnection.gql
├── codegen.yml
├── components
│   └── TodoPaging.vue
├── gql-types.d.ts
├── layouts
│   ├── default.vue
│   └── error.vue
├── nuxt.config.js
├── package.json
├── pages
│   └── index.vue
├── plugins
│   └── apollo-error-handler.js
├── tsconfig.json
├── types
│   └── vuetify
│       └── index.d.ts
├── vue-shim.d.ts
└── yarn.lock
```

### ■DBの中身

#### `user`テーブル
![Screenshot from 2020-02-08 22-46-25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/f08ef53d-c449-37c9-aeef-158de322ea69.png)
#### `todo`テーブル
![Screenshot from 2020-02-08 22-45-53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/85700e24-5584-9871-9622-d5052b5f6748.png)

### ■ソース解説

#### GraphQLスキーマに合わせた型定義ファイル自動生成

やり方は以下参照。
「[graphql-codegenでフロントエンドをGraphQLスキーマファースト](https://qiita.com/sky0621/items/f03631f0309f5c375b1d)」

<details><summary>コマンド実行と型定義ファイル自動生成による差分</summary><div>

```
$ npm run codegen

> frontend@1.0.0 codegen /home/sky0621/src/github.com/sky0621/study-graphql/src/frontend
> graphql-codegen

  ✔ Parse configuration
  ✔ Generate outputs


   ╭────────────────────────────────────────────────────────────────╮
   │                                                                │
   │      New patch version of npm available! 6.13.1 → 6.13.7       │
   │   Changelog: https://github.com/npm/cli/releases/tag/v6.13.7   │
   │               Run npm install -g npm to update!                │
   │                                                                │
   ╰────────────────────────────────────────────────────────────────╯

$
$ git statusOn branch master
Your branch is up to date with 'origin/master'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

	modified:   gql-types.d.ts

no changes added to commit (use "git add" and/or "git commit -a")

$
$ git diff
diff --git a/src/frontend/gql-types.d.ts b/src/frontend/gql-types.d.ts
index 36495a0..8db10de 100644
--- a/src/frontend/gql-types.d.ts
+++ b/src/frontend/gql-types.d.ts
@@ -6,15 +6,73 @@ export type Scalars = {
   Boolean: boolean,
   Int: number,
   Float: number,
+  /** カーソル（１レコードをユニークに特定する識別子） */
+  Cursor: any,
 };
 
+/** 前ページ遷移条件 */
+export type BackwardPagination = {
+  /** 取得件数 */
+  last: Scalars['Int'],
+  /** 取得対象識別用カーソル（※前ページ遷移時にこのカーソルよりも前にあるレコードが取得対象） */
+  before?: Maybe<Scalars['Cursor']>,
+};
+
+/** ページングを伴う結果返却用 */
+export type Connection = {
+  /** ページ情報 */
+  pageInfo: PageInfo,
+  /** 結果一覧（※カーソル情報を含む） */
+  edges: Array<Edge>,
+  /** 検索結果の全件数 */
+  totalCount: Scalars['Int'],
+};
+
+
+/** 検索結果一覧（※カーソル情報を含む） */
+export type Edge = {
+  /** Nodeインタフェースを実装したtypeなら代入可能 */
+  node?: Maybe<Node>,
+  cursor: Scalars['Cursor'],
+};
+
+/** 並び替え条件 */
+export type EdgeOrder = {
+  /** 並べ替えキー項目 */
+  key: OrderKey,
+  /** ソート方向 */
+  direction: OrderDirection,
+};
+
+/** 次ページ遷移条件 */
+export type ForwardPagination = {
+  /** 取得件数 */
+  first: Scalars['Int'],
+  /** 取得対象識別用カーソル（※次ページ遷移時にこのカーソルよりも後ろにあるレコードが取得対象） */
+  after?: Maybe<Scalars['Cursor']>,
+};
+
+/** マッチングパターン種別（※要件次第で「前方一致」や「後方一致」も追加） */
+export enum MatchingPattern {
+  /** 部分一致 */
+  PartialMatch = 'PARTIAL_MATCH',
+  /** 完全一致 */
+  ExactMatch = 'EXACT_MATCH'
+}
+
 export type Mutation = {
    __typename?: 'Mutation',
+  noop?: Maybe<NoopPayload>,
   createTodo: Scalars['ID'],
   createUser: Scalars['ID'],
 };
 
 
+export type MutationNoopArgs = {
+  input?: Maybe<NoopInput>
+};
+
+
 export type MutationCreateTodoArgs = {
   input: NewTodo
 };
@@ -33,32 +91,150 @@ export type NewUser = {
   name: Scalars['String'],
 };
 
+export type Node = {
+  id: Scalars['ID'],
+};
+
+export type NoopInput = {
+  clientMutationId?: Maybe<Scalars['String']>,
+};
+
+export type NoopPayload = {
+   __typename?: 'NoopPayload',
+  clientMutationId?: Maybe<Scalars['String']>,
+};
+
+/** 並べ替え方向 */
+export enum OrderDirection {
+  /** 昇順 */
+  Asc = 'ASC',
+  /** 降順 */
+  Desc = 'DESC'
+}
+
+/** 
+ * 並べ替えのキー
+ * 汎用的な構造にしたいが以下はGraphQLの仕様として不可だった。
+ * ・enum・・・汎化機能がない。
+ * ・interface・・・inputには実装機能がない。
+ * ・union・・・inputでは要素に持てない。
+ * とはいえ、並べ替えも共通の仕組みとして提供したく、結果として機能毎に enum フィールドを列挙
+ */
+export type OrderKey = {
+  /** TODO一覧の並べ替えキー */
+  todoOrderKey?: Maybe<TodoOrderKey>,
+};
+
+/** ページング条件 */
+export type PageCondition = {
+  /** 前ページ遷移条件 */
+  backward?: Maybe<BackwardPagination>,
+  /** 次ページ遷移条件 */
+  forward?: Maybe<ForwardPagination>,
+  /** 現在ページ番号（今回のページング実行前の時点のもの） */
+  nowPageNo: Scalars['Int'],
+  /** １ページ表示件数 */
+  initialLimit?: Maybe<Scalars['Int']>,
+};
+
+/** ページ情報 */
+export type PageInfo = {
+   __typename?: 'PageInfo',
+  /** 次ページ有無 */
+  hasNextPage: Scalars['Boolean'],
+  /** 前ページ有無 */
+  hasPreviousPage: Scalars['Boolean'],
+  /** 当該ページの１レコード目 */
+  startCursor: Scalars['Cursor'],
+  /** 当該ページの最終レコード */
+  endCursor: Scalars['Cursor'],
+};
+
 export type Query = {
    __typename?: 'Query',
+  node?: Maybe<Node>,
   todos: Array<Todo>,
   todo: Todo,
+  /** Relay準拠ページング対応検索によるTODO一覧取得 */
+  todoConnection?: Maybe<TodoConnection>,
   users: Array<User>,
   user: User,
 };
 
 
+export type QueryNodeArgs = {
+  id: Scalars['ID']
+};
+
+
 export type QueryTodoArgs = {
   id: Scalars['ID']
 };
 
 
+export type QueryTodoConnectionArgs = {
+  filterWord?: Maybe<TextFilterCondition>,
+  pageCondition?: Maybe<PageCondition>,
+  edgeOrder?: Maybe<EdgeOrder>
+};
+
+
 export type QueryUserArgs = {
   id: Scalars['ID']
 };
 
-export type Todo = {
+/** 文字列フィルタ条件 */
+export type TextFilterCondition = {
+  /** フィルタ文字列 */
+  filterWord: Scalars['String'],
+  /** マッチングパターン（※オプション。指定無しの場合は「部分一致」となる。） */
+  matchingPattern?: Maybe<MatchingPattern>,
+};
+
+export type Todo = Node & {
    __typename?: 'Todo',
+  /** ID */
   id: Scalars['ID'],
+  /** TODO */
   text: Scalars['String'],
+  /** 済みフラグ */
   done: Scalars['Boolean'],
+  /** 作成日時 */
+  createdAt: Scalars['Int'],
+  /** ユーザー情報 */
   user: User,
 };
 
+/** ページングを伴う結果返却用 */
+export type TodoConnection = Connection & {
+   __typename?: 'TodoConnection',
+  /** ページ情報 */
+  pageInfo: PageInfo,
+  /** 検索結果一覧（※カーソル情報を含む） */
+  edges: Array<TodoEdge>,
+  /** 検索結果の全件数 */
+  totalCount: Scalars['Int'],
+};
+
+/** 検索結果一覧（※カーソル情報を含む） */
+export type TodoEdge = Edge & {
+   __typename?: 'TodoEdge',
+  node?: Maybe<Todo>,
+  cursor: Scalars['Cursor'],
+};
+
+/** TODO並べ替えキー */
+export enum TodoOrderKey {
+  /** TODO */
+  Text = 'TEXT',
+  /** 済みフラグ */
+  Done = 'DONE',
+  /** 作成日時（初期表示時のデフォルト） */
+  CreatedAt = 'CREATED_AT',
+  /** ユーザー名 */
+  UserName = 'USER_NAME'
+}
+
 export type User = {
    __typename?: 'User',
   id: Scalars['ID'],
```

</div></details>

#### GraphQLクエリ

GraphQLサーバ側のスキーマが↓のようになっている。
https://qiita.com/sky0621/items/1e8823200633f2c46013#graphqlスキーマ-1
なので、クエリは、こうなる。

```graphql:src/frontend/apollo/queries/todoConnection.gql
query todoConnection(
  $filterWord: TextFilterCondition
  $pageCondition: PageCondition
  $edgeOrder: EdgeOrder
) {
  todoConnection(
    filterWord: $filterWord
    pageCondition: $pageCondition
    edgeOrder: $edgeOrder
  ) {
    pageInfo {
      hasNextPage
      hasPreviousPage
      startCursor
      endCursor
    }
    edges {
      node {
        id
        text
        done
        createdAt
        user {
          id
          name
        }
      }
      cursor
    }
    totalCount
  }
}
```

#### ページ

ページング実装を施した一覧を表示するページ。
ここは単純にコンポーネント「`TodoPaging`」を呼ぶだけ。

```vue:src/frontend/pages/index.vue
<template>
  <div>
    <TodoPaging />
  </div>
</template>

<script lang="ts">
import { Vue, Component } from 'nuxt-property-decorator'
import TodoPaging from '~/components/TodoPaging.vue'

@Component({
  components: { TodoPaging }
})
export default class IndexPage extends Vue {}
</script>
```

#### コンポーネント

##### 初期段階

さて、今回の主役である「`TodoPaging`」コンポーネント。
ただ、いきなりすべて実装しきってから提示すると、若干わかりづらいものになる気がするので、段階を経てみることにする。
まずは、以下の部分だけ実装したコンポーネントを作ってみる。
・「文字列フィルタ」テキストボックス
・ヘッダだけ定義して、あとはデフォルト設定の空データの`v-data-table`
![Screenshot from 2020-02-08 23-22-17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/0b5e1093-6ac4-6823-9478-d4b8e30e84af.png)

ソースは下記のような感じ。
`v-data-table`は標準でいろいろ付いているので今回レベルで言うとヘッダを定義するコードを書くだけでいい。

```vue:src/frontend/components/TodoPaging.vue
<template>
  <v-form>
    <!-- 「文字列フィルタ」テキストボックス表示エリア -->
    <v-row>
      <v-col col="5">
        <v-card class="pa-4">
          <v-text-field v-model="search" label="Search"></v-text-field>
        </v-card>
      </v-col>
    </v-row>
    <!-- ページング込みの一覧テーブル表示エリア -->
    <v-row>
      <v-col col="9">
        <v-card>
          <v-data-table :search="search" :headers="headers" fixed-header>
          </v-data-table>
        </v-card>
      </v-col>
    </v-row>
  </v-form>
</template>
<script lang="ts">
import { Component, Vue } from '@/node_modules/nuxt-property-decorator'
// eslint-disable-next-line no-unused-vars
import { DataTableHeader } from '@/types/vuetify'

// v-data-tableにおけるヘッダーの定義用
class DataTableHeaderImpl implements DataTableHeader {
  text: string
  value: string
  sortable: boolean
  width: number
  constructor(text: string, value: string, sortable: boolean, width: number) {
    this.text = text
    this.value = value
    this.sortable = sortable
    this.width = width
  }
}

@Component({})
export default class TodoPaging extends Vue {
  // 文字列フィルタ入力値の受け口
  private readonly search = ''

  // 一覧テーブルのヘッダー表示要素の配列
  private readonly headers: DataTableHeader[] = [
    new DataTableHeaderImpl('ID', 'id', false, 50),
    new DataTableHeaderImpl('TODO', 'text', true, 50),
    new DataTableHeaderImpl('Done', 'done', true, 50),
    new DataTableHeaderImpl('CreatedAt(UnixTimestamp)', 'createdAt', true, 50),
    new DataTableHeaderImpl('User', 'user.name', false, 50)
  ]
}
</script>
```

##### 第２段階

次は、GraphQLサーバからTODO一覧を取得して`v-data-table`に表示する。
ただし、この段階では、ページングや文字列フィルタによる絞り込み機能等は入れず、単純に全件を取得する。

![Screenshot from 2020-02-09 21-56-36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/aba6faa3-0de5-e096-c814-4f3dc9e222f3.png)
　　　　　　　　　　　　　　　　　　　　　〜〜〜
![Screenshot from 2020-02-09 21-56-51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/b26b3c26-d79e-0992-cb9c-a509a4148018.png)

ソースは下記のような感じ。

```vue:src/frontend/components/TodoPaging.vue（主に前回との差分を抜粋）
<template>
  <v-form>
    <!-- 「文字列フィルタ」テキストボックス表示エリア -->
　　　　　〜〜省略〜〜
    <!-- ページング込みの一覧テーブル表示エリア -->
    <v-row>
      <v-col col="9">
        <v-card>
          <v-data-table
            :search="search"
            :headers="headers"
            :items="items"
            :options.sync="options"
            :server-items-length="totalCount"
            fixed-header
          >
          </v-data-table>
　　〜〜省略〜〜
</template>
<script lang="ts">
import { Component, Vue, Watch } from '~/node_modules/nuxt-property-decorator'
// eslint-disable-next-line no-unused-vars
import { DataTableHeader } from '~/types/vuetify'
import todoConnection from '~/apollo/queries/todoConnection.gql'

// v-data-tableにおけるヘッダーの定義用
　　〜〜省略〜〜

// v-data-tableにおけるページング・ソート条件値の受け取り用
class DataTableOptions {
  public page: number = 1
  public itemsPerPage: number = 10
  // MEMO: 現状では一度に指定できるソートキーは１つ
  public sortBy: Array<string> = []
  public sortDesc: Array<boolean> = []
}

@Component({})
export default class TodoPaging extends Vue {
  // 文字列フィルタ入力値の受け口
  private readonly search = ''

  // 一覧テーブルのヘッダー表示要素の配列
  　　〜〜省略〜〜

  // 一覧テーブルのデータ（v-data-tableの状態変更をウォッチし、その変更を契機にGraphQLクエリ発行→結果を格納）
  // eslint-disable-next-line no-array-constructor
  private items = new Array<Node>()

  // v-data-tableの状態変更をウォッチするための受け皿
  private options = new DataTableOptions()

  // ページングに依らない検索条件に合致する総件数を保持
  private totalCount: number = 0

  // v-data-tableの状態変更をウォッチし、その変更を契機にconnection関数をコール
  @Watch('options')
  watchOptions() {
    this.connection()
  }

  // Apolloライブラリを使ってGraphQLサーバにクエリ発行
  private async connection() {
    try {
      // $apollo.query()がPromiseを返すのでasync/awaitで受け取り
      // まずは、ページング・並べ替え条件等を指定せず、単純にクエリを叩く
      const res = await this.$apollo.query({
        query: todoConnection
      })

      if (res && res.data && res.data.todoConnection) {
        const conn = res.data.todoConnection

        // 一覧表示するデータを抜き出す
        // edges [ node {id, text, done, ...} ]
        this.items = conn.edges.filter((e) => e.node).map((e) => e.node)

        // ページングに依らない検索条件に合致する総件数を保持
        this.totalCount = conn.totalCount
      } else {
        console.log('no result')
      }
    } catch (e) {
      console.log(e)
    }
  }
}
</script>
```

##### 最終段階

いよいよ、文字列フィルタ、ページング条件、並べ替え条件にも対応する。
初期表示では、以下のように表示される。
![Screenshot from 2020-02-09 23-37-49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/0a570fee-25f7-a987-3604-c8da0e7a8c27.png)
次へボタン押下すると、
![Screenshot from 2020-02-09 23-56-27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/e5e5d081-0428-b9f2-0b5b-475d7f3fd64b.png)
２ページ目（１８件しかないところを１０件ずつ表示しているので、２ページ目＝最終ページ）が表示される。
![Screenshot from 2020-02-09 23-56-42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/754b97c5-6c4c-f1d8-9d17-5c0d379a7459.png)
この動き自体は今回の対応前（つまりページングでなく初回に全レコードをフロントで取得済み＝つどつどサーバに通信いかない）と同じなので、ちゃんと通信いってるのか確認。
![Screenshot from 2020-02-09 23-57-27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/5f7b25a7-e24e-4bb9-6519-c85f6d9f8c16.png)
うん、大丈夫なようだ。

戻ってみる。
![Screenshot from 2020-02-10 00-01-26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/9e019e25-8eef-f4fb-cd6f-24dfa08d7c75.png)

続いて、文字列フィルタを試してみる。
残念ながら全フィールドに適用されるフィルタではないのだけど、とりあえず適用先として実装済みな「TODO」欄をターゲットに「`taro`」で絞り込んでみる。
![Screenshot from 2020-02-10 00-02-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/b5f45b34-3053-c5f7-ef6d-bd7e108983b3.png)
期待通り、６件が表示されている。ボタンの活性制御も大丈夫なようだ。
↓ちゃんと検索条件としてリクエストに積まれていた。
![Screenshot from 2020-02-10 00-05-32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/dab60118-9c1d-84cc-40e4-df41cae1bc1f.png)

文字列フィルタを解除すると、元通り全件を対象とした１ページ目の表示に戻った。
![Screenshot from 2020-02-10 00-06-24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/e69e4dc0-a663-5cb6-0a09-d50ed780011b.png)

お次は、並べ替え。これも残念ながら全フィールドに適用しているわけではないのだけど、適用先として実装済みな「TODO」欄を昇順にしてみる。
![Screenshot from 2020-02-10 00-07-22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/9587db49-2425-516b-34b7-248c7a1db060.png)
うん、よい。
この並べ替えのままで次のページに遷移してみる。
![Screenshot from 2020-02-10 00-09-40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/2e76b208-1bb9-9a73-ea9f-0f847229fe04.png)
まあ、当たり前だけど、ちゃんと昇順のまま次のページが表示される。

あとは、１ページあたりの表示件数を変えてみるぐらいかな。５件に変えてみる。
![Screenshot from 2020-02-10 00-11-29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/e1711a61-17e8-3ae1-c7ac-35ac50226252.png)

↓うん、並べ替え順も維持されているまま、表示が５件になった。
![Screenshot from 2020-02-10 00-12-23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/7c98871b-7d1f-16c7-10b1-7ef48e8b3f4c.png)

念の為、次のページにも遷移してみる。
![Screenshot from 2020-02-10 00-13-37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/c30e6e3f-95b9-e708-a0be-89430d46660f.png)
よい。
次へ、次へ、最後まで。
![Screenshot from 2020-02-10 00-13-49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/afa8ac56-cead-c0c3-67c4-28b2562558e1.png)
![Screenshot from 2020-02-10 00-13-59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/bafa7d3b-e70a-d3e9-58c4-611ee9a61b6b.png)
いいね。
念には念を入れ、ここから前のページに戻ってみる。
![Screenshot from 2020-02-10 00-14-12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/347c1f21-a603-0bb0-5c82-2d284b2d6c54.png)

では、最後。１ページあたりの表示件数を「ALL」に変えてみよう。
![Screenshot from 2020-02-10 00-15-59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/be40d952-aff9-b9fa-6ff0-234ea5f0f286.png)
![Screenshot from 2020-02-10 00-16-18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/d937efaf-e0c8-18fe-d180-6892974ab964.png)
　　　　　　　　　　　　　　　　　　　〜〜省略〜〜
![Screenshot from 2020-02-10 00-16-26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/f00dbcae-950e-cabe-9a95-868e1f5f9406.png)


ソースは下記のような感じ。最終段階なので全量を記載。
↓はテンプレート部分。

```vue:src/frontend/components/TodoPaging.vue（まずは表示テンプレート部分）
<template>
  <v-form>
    <!-- 「文字列フィルタ」テキストボックス表示エリア -->
    <v-row>
      <v-col col="5">
        <v-card class="pa-4">
          <v-text-field v-model="search" label="Search"></v-text-field>
        </v-card>
      </v-col>
    </v-row>
    <!-- ページング込みの一覧テーブル表示エリア -->
    <v-row>
      <v-col col="9">
        <v-card>
          <v-data-table
            :search="search"
            :headers="headers"
            :items="items"
            :options.sync="options"
            :server-items-length="totalCount"
            fixed-header
          >
          </v-data-table>
        </v-card>
      </v-col>
    </v-row>
  </v-form>
</template>
```

↓スクリプト部分の開始。各種import文やVueコンポーネントで使うクラス群を定義。

```vue:src/frontend/components/TodoPaging.vue（スクリプト部分：Vueコンポーネントで使うクラス群）
<script lang="ts">
import { Component, Vue, Watch } from '~/node_modules/nuxt-property-decorator'
// eslint-disable-next-line no-unused-vars
import { DataTableHeader } from '~/types/vuetify'
import todoConnection from '~/apollo/queries/todoConnection.gql'
// eslint-disable-next-line no-unused-vars
import { Edge, EdgeOrder, PageCondition } from '~/gql-types'

// v-data-tableにおけるヘッダーの定義用
class DataTableHeaderImpl implements DataTableHeader {
  text: string
  value: string
  sortable: boolean
  width: number
  constructor(text: string, value: string, sortable: boolean, width: number) {
    this.text = text
    this.value = value
    this.sortable = sortable
    this.width = width
  }
}

// v-data-tableにおけるページング・ソート条件値の受け取り用
class DataTableOptions {
  public page: number = 1
  public itemsPerPage: number = 10
  // MEMO: 現状では一度に指定できるソートキーは１つ
  public sortBy: Array<string> = []
  public sortDesc: Array<boolean> = []
}
```

↓ここからようやくVueコンポーネント自身を表す定義。

```vue:src/frontend/components/TodoPaging.vue（スクリプト部分）
@Component({})
export default class TodoPaging extends Vue {
  // 文字列フィルタ入力値の受け口
  private readonly search = ''

  // 一覧テーブルのヘッダー表示要素の配列
  private readonly headers: DataTableHeader[] = [
    new DataTableHeaderImpl('ID', 'id', false, 50),
    new DataTableHeaderImpl('TODO', 'text', true, 50),
    new DataTableHeaderImpl('Done', 'done', true, 50),
    new DataTableHeaderImpl('CreatedAt(UnixTimestamp)', 'createdAt', true, 50),
    new DataTableHeaderImpl('User', 'user.name', false, 50)
  ]

  // 一覧テーブルのデータ（v-data-tableの状態変更をウォッチし、その変更を契機にGraphQLクエリ発行→結果を格納）
  // eslint-disable-next-line no-array-constructor
  private items = new Array<Node>()

  // v-data-tableの状態変更をウォッチするための受け皿
  private options = new DataTableOptions()

  // ページングに依らない検索条件に合致する総件数を保持
  private totalCount: number = 0

  // 今回のページの１番目のレコードを表す識別子
  private startCursor: string | null = null

  // 今回のページの最後のレコードを表す識別子
  private endCursor: string | null = null

  // 現在のページを表す（これも、GraphQLサーバに渡すパラメータとして必要）
  private nowPage: number = 1
```

↓は、文字列フィルタでの入力状況をウォッチするための定義と、`v-data-table`の状態変更をウォッチするための定義。

```vue:src/frontend/components/TodoPaging.vue（スクリプト部分）
  // 文字列フィルタ欄の入力を監視
  @Watch('search')
  watchSearchWord() {
    this.initPageParam()
    this.connection()
  }

  // v-data-tableの状態変更をウォッチし、その変更を契機にconnection関数をコール
  @Watch('options')
  watchOptions() {
    // MEMO: ソートや１ページあたり表示件数の変更時は「1」が渡される。
    if (this.options.page === 1) {
      this.initPageParam()
    }
    this.connection()
  }

  // 初期表示時やページング条件をクリアしたいタイミングでコールする関数
  private initPageParam(): void {
    this.nowPage = 1
    this.options.page = 1
  }
```

↓Apolloライブラリを使ったGraphQLサーバへのクエリ発行。

```vue:src/frontend/components/TodoPaging.vue（スクリプト部分）
  // Apolloライブラリを使ってGraphQLサーバにクエリ発行
  private async connection() {
    try {
      // $apollo.query()がPromiseを返すのでasync/awaitで受け取り
      const res = await this.$apollo.query({
        query: todoConnection,
        variables: {
          // 文字列フィルタ条件
          filterWord: { filterWord: this.search },
          // ページング条件
          pageCondition: this.createPageCondition(
            this.nowPage, // 現在のページ
            this.options.page, // 遷移先のページ
            this.options.itemsPerPage, // １ページあたりの表示件数指定
            this.startCursor,
            this.endCursor
          ),
          // 並び替え条件
          edgeOrder: this.createEdgeOrder(
            this.options.sortBy,
            this.options.sortDesc
          )
        }
      })

      if (res && res.data && res.data.todoConnection) {
        const conn = res.data.todoConnection

        // 一覧表示するデータを抜き出す
        // edges [ node {id, text, done, ...} ]
        this.items = conn.edges
          .filter((e: Edge) => e.node)
          .map((e: Edge) => e.node)

        // ページングに依らない検索条件に合致する総件数を保持
        this.totalCount = conn.totalCount

        // v-data-tableのoptions変更に影響する各種ページ情報を保持
        const pageInfo = conn.pageInfo
        this.startCursor = pageInfo.startCursor
        this.endCursor = pageInfo.endCursor

        this.nowPage = this.options.page
      } else {
        console.log('no result')
      }
    } catch (e) {
      console.log(e)
    }
  }
```

↓は、GraphQLクエリ発行時のパラメータ生成関数群。

```vue:src/frontend/components/TodoPaging.vue（スクリプト部分）
  private createPageCondition(
    nowPage: number,
    nextPage: number,
    limit: number,
    startCursor: string | null,
    endCursor: string | null
  ): PageCondition {
    // 現在のページと遷移指示先のページとの比較によって「次へ(forward)」なのか「前へ(backward)」なのか判別
    return {
      forward: nowPage < nextPage ? { first: limit, after: endCursor } : null,
      backward:
        nowPage > nextPage ? { last: limit, before: startCursor } : null,
      nowPageNo: nowPage,
      initialLimit: limit > 0 ? limit : null
    }
  }

  private createEdgeOrder(
    sortBy: Array<string>,
    sortDesc: Array<boolean>
  ): EdgeOrder | null {
    if (sortBy && sortDesc) {
      // MEMO: 現状では一度に指定できるソートキーは１つ
      if (sortBy.length !== 1 || sortDesc.length !== 1) {
        return null
      }
      // TODO: enum値を指定するとビルドが通らなくなるので、やむなく文字列で指定
      const direction = sortDesc[0] ? 'DESC' : 'ASC'
      switch (sortBy[0]) {
        case 'text':
          return { key: { todoOrderKey: 'TEXT' }, direction }
        case 'done':
          return { key: { todoOrderKey: 'DONE' }, direction }
        case 'createdAt':
          return { key: { todoOrderKey: 'CREATED_AT' }, direction }
      }
    }
    return null
  }
}
</script>
```

# まとめ
正直、まだバグはあると思う。たぶん。文字列フィルタとページ遷移と１ページ表示件数の変更などいろいろ組み合わせた時などのケースで。
それにしても、TypeScript難しい。いや、Nuxt.jsとの組み合わせだからなのかな・・・。

あらためまして、今回分の全ソースは下記。
https://github.com/sky0621/study-graphql/tree/v0.8.0