Help us understand the problem. What is going on with this article?

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

お題

前回は表題におけるバックエンド部分を実装し、GraphQLのプレイグラウンドでの実行上で動作確認をした。
今回は、フロントエンドでGraphQLクライアントを用いた実装を行う。
ページング機能含むテーブルを持つ画面のデザインはVuetifyData tablesを採用。

機能

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

イメージとしては↓のような感じ。
◆1ページ目
Screenshot from 2020-02-06 23-58-18.png
◆2ページ目
Screenshot from 2020-02-06 23-58-45.png

関連記事索引

開発環境

# 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

■プロジェクト構成

関係あるところだけ抜粋
$ 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

todoテーブル

Screenshot from 2020-02-08 22-45-53.png

■ソース解説

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

やり方は以下参照。
graphql-codegenでフロントエンドをGraphQLスキーマファースト

コマンド実行と型定義ファイル自動生成による差分
$ 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,
+  /** カーソル(1レコードをユニークに特定する識別子) */
+  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'],
+  /** 1ページ表示件数 */
+  initialLimit?: Maybe<Scalars['Int']>,
+};
+
+/** ページ情報 */
+export type PageInfo = {
+   __typename?: 'PageInfo',
+  /** 次ページ有無 */
+  hasNextPage: Scalars['Boolean'],
+  /** 前ページ有無 */
+  hasPreviousPage: Scalars['Boolean'],
+  /** 当該ページの1レコード目 */
+  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'],

GraphQLクエリ

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

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」を呼ぶだけ。

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

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

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>
第2段階

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

Screenshot from 2020-02-09 21-56-36.png
                     〜〜〜
Screenshot from 2020-02-09 21-56-51.png

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

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: 現状では一度に指定できるソートキーは1つ
  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
次へボタン押下すると、
Screenshot from 2020-02-09 23-56-27.png
2ページ目(18件しかないところを10件ずつ表示しているので、2ページ目=最終ページ)が表示される。
Screenshot from 2020-02-09 23-56-42.png
この動き自体は今回の対応前(つまりページングでなく初回に全レコードをフロントで取得済み=つどつどサーバに通信いかない)と同じなので、ちゃんと通信いってるのか確認。
Screenshot from 2020-02-09 23-57-27.png
うん、大丈夫なようだ。

戻ってみる。
Screenshot from 2020-02-10 00-01-26.png

続いて、文字列フィルタを試してみる。
残念ながら全フィールドに適用されるフィルタではないのだけど、とりあえず適用先として実装済みな「TODO」欄をターゲットに「taro」で絞り込んでみる。
Screenshot from 2020-02-10 00-02-02.png
期待通り、6件が表示されている。ボタンの活性制御も大丈夫なようだ。
↓ちゃんと検索条件としてリクエストに積まれていた。
Screenshot from 2020-02-10 00-05-32.png

文字列フィルタを解除すると、元通り全件を対象とした1ページ目の表示に戻った。
Screenshot from 2020-02-10 00-06-24.png

お次は、並べ替え。これも残念ながら全フィールドに適用しているわけではないのだけど、適用先として実装済みな「TODO」欄を昇順にしてみる。
Screenshot from 2020-02-10 00-07-22.png
うん、よい。
この並べ替えのままで次のページに遷移してみる。
Screenshot from 2020-02-10 00-09-40.png
まあ、当たり前だけど、ちゃんと昇順のまま次のページが表示される。

あとは、1ページあたりの表示件数を変えてみるぐらいかな。5件に変えてみる。
Screenshot from 2020-02-10 00-11-29.png

↓うん、並べ替え順も維持されているまま、表示が5件になった。
Screenshot from 2020-02-10 00-12-23.png

念の為、次のページにも遷移してみる。
Screenshot from 2020-02-10 00-13-37.png
よい。
次へ、次へ、最後まで。
Screenshot from 2020-02-10 00-13-49.png
Screenshot from 2020-02-10 00-13-59.png
いいね。
念には念を入れ、ここから前のページに戻ってみる。
Screenshot from 2020-02-10 00-14-12.png

では、最後。1ページあたりの表示件数を「ALL」に変えてみよう。
Screenshot from 2020-02-10 00-15-59.png
Screenshot from 2020-02-10 00-16-18.png
                   〜〜省略〜〜
Screenshot from 2020-02-10 00-16-26.png

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

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コンポーネントで使うクラス群を定義。

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: 現状では一度に指定できるソートキーは1つ
  public sortBy: Array<string> = []
  public sortDesc: Array<boolean> = []
}

↓ここからようやく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

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

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

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

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

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

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

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

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

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, // 1ページあたりの表示件数指定
            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クエリ発行時のパラメータ生成関数群。

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: 現状では一度に指定できるソートキーは1つ
      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>

まとめ

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

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

sky0621
Go使い。最近はRustラブ。Webアプリケーション作ることが多い。フロントエンドもクラウド(GCP好き)もそれなりに触る。2019/10からGraphQLも嗜む。
https://github.com/sky0621/Curriculum-Vitae/blob/master/README.md
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away