Laravel9 + Vite + Vue3 環境でページネーション機能を実装する

# はじめに
一覧画面においてページネーションはよくある機能の一つですが、一から実装するとなると地味に面倒な機能でもあります。
ところがLaravelにはデータの取得から表示、スタイルの調整まで簡単に行えるような機能が備わっているので一安心…そう、Bladeであれば！！

でも、BladeではなくVueを利用した場合はどうしよう！？

# ペジネータの手作業生成
Bladeであれば、下記のようにするだけでページリンクをレンダリングしてくれますが、もちろんVueでは利用できません。

```php
{{ $users->links() }}
```

「自前でページネーション用のコンポーネントを用意してやる必要があるのですが、果たしてリンクやラベルはどうやって取得できるのか…？」

特にすることはありませんでした。

https://readouble.com/laravel/9.x/ja/pagination.html
> Laravelペジネータクラスは`Illuminate\Contracts\Support\Jsonable`インターフェイスコントラクトを実装し、`toJson`メソッドを提供しているため、ペジネーションの結果をJSONに変換するのは非常に簡単です。ルートまたはコントローラアクションから返すことで、ペジネーションインスタンスをJSONに変換することもできます。

上記の通り、ペジネーションの結果をJSONでフロントに渡してやることで、オリジナルのページャーを実装できるようになりました。


ところが、新たな課題が出てきました。
「DBから取得したデータを加工してから返したいんだけど、どうすればいいのか…？」

これは少し面倒でしたが、Laravelには自前でペジネーションインスタンスを生成する方法が用意されておりました。

https://readouble.com/laravel/9.x/ja/pagination.html
> 場合によっては、ペジネーションインスタンスを手作業で作成し、メモリ内にすでにあるアイテムの配列を渡すことができます。必要に応じて、`Illuminate\Pagination\Paginator`、`Illuminate\Pagination\LengthAwarePaginator`、`Illuminate\Pagination\CursorPaginator`インスタンスを生成することでこれが行えます。

自前でページネーションを生成したサンプルがこちら。

```app/Services/StaffService.php
class StaffService
{
    public function list(Request $request)
    {
        list(
            $limit,
            $page,
            $query,
        ) = [
            isset($request->limit) ? intval($request->limit) : 10,
            isset($request->page) ? intval($request->page) : 1,
            isset($request->query) ? $query : null
        ];

        try
        {
            // 全ページのデータ取得
            $staffsQuery = Staff::whereNotNull('enabled_at');

            // 対象ページのデータ取得
            $total = $staffsQuery->count();
            $staffs = $staffsQuery
                ->offset(($page - 1) * $limit)
                ->limit($limit)
                ->get();

            $items = array();

            // 取得したデータの加工
            foreach ($staffs as $staff) {
                $created_at = new Carbon($staff->created_at);

                $item = array(
                    'id'            => $staff->id,
                    'name'          => $staff->name,
                    'email'         => $staff->email,
                    'created_at'    => $created_at->timezone('Asia/Tokyo')->format('Y年m月d日 H:i'),
                );

                array_push($items, $item);
            }

            // ページのパスを指定
            $options = array(
                'path' => LengthAwarePaginator::resolveCurrentPath()
            );

            // ページネーションの作成
            $result = new LengthAwarePaginator(
                $items,
                $total,
                $limit,
                $page,
                $options
            );

            $response = array(
                'items' => $result,
                'query' => $query
            );

            return $response;
        }
        catch (Exception $exception)
        {
            Log::error($exception->getMessage());
        }
    }
}
```

あとは結果をVueファイルに渡してあげれば、バックエンド側は完了です。

```app/Http/Controllers/StaffController.php
class StaffController extends Controller
{
    public function list(Request $request)
    {
        $response = $this->staffService->list($request);

        return Inertia::render('Staff/List', [
            'items' => $response['items'],
            'query' => $response['query'],
        ]);
    }
```

# ページネーションコンポーネントの作成

各一覧画面で利用できるようにコンポーネント化します。
バックエンド側で生成したページネーションのリンク一覧を`links`として、検索条件となるURLクエリパラメータを文字列で`query`として、画面側のソースから受け取れるようにします。

```vue
const props = defineProps({
    links: {
        type: Array,
    },
    query: {
        type: String,
        default: ''
    }
});
```

ページネーションのベースは`tailwindcss`のソースを利用しました。
https://flowbite.com/docs/components/pagination/

ページネーションのリンクの一覧をループさせて表示し、インデックスによって「前へ」「ページ番号」「次へ」のリンクを出し分けます。
URLクエリパラメータがあれば、ページネーションリンクに付け足しています。

```vue
<template v-if='key === 0'>
    <!-- Previous -->
    <Link
        class="block p-2 mr-2 leading-tight bg-neutral-500 text-white dark:bg-neutral-500 dark:text-white rounded-full hover:bg-neutral-400 dark:hover:bg-neutral-400"
        :class="{ 'bg-blue-700 text-white': link.active }"
        :href="link.url ? link.url + (query ? '&' + query : '') : '#'"
    >
        <span class="sr-only">Previous</span>
        <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="4" stroke="currentColor" class="w-3 h-3">
            <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15m0 0l6.75 6.75M4.5 12l6.75-6.75" />
        </svg>
    </Link>
</template>
```

ページネーションコンポーネントのサンプルがこちら。

```resources/ts/Components/Paginate.vue
<script setup>
import { Link } from '@inertiajs/vue3';

const props = defineProps({
    links: {
        type: Array,
    },
    query: {
        type: String,
        default: ''
    }
});
</script>

<template>
    <div v-if="links.length > 3">
        <ul class="inline-flex items-center -space-x-px">
            <li v-for="(link, key) in links" :key="key">
                <template v-if='key === 0'>
                    <!-- Previous -->
                    <Link
                        class="block p-2 mr-2 leading-tight bg-neutral-500 text-white dark:bg-neutral-500 dark:text-white rounded-full hover:bg-neutral-400 dark:hover:bg-neutral-400"
                        :class="{ 'bg-blue-700 text-white': link.active }"
                        :href="link.url ? link.url + (query ? '&' + query : '') : '#'"
                    >
                        <span class="sr-only">Previous</span>
                        <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="4" stroke="currentColor" class="w-3 h-3">
                            <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15m0 0l6.75 6.75M4.5 12l6.75-6.75" />
                        </svg>
                    </Link>
                </template>

                <template v-else-if='key !== 0 && key !== links.length - 1'>
                    <!-- Number -->
                    <span
                        v-if="link.url === null"
                        class="px-4 py-2 leading-tight text-neutral-300 dark:text-neutral-300"
                        v-html="link.label"
                    />
                    <Link
                        v-else
                        class="px-4 py-2 leading-tight"
                        :class="link.active ? 'text-neutral-500 dark:text-neutral-500 hover:text-neutral-400 dark:hover:text-neutral-400' : 'text-blue-600 underline dark:text-blue-500 hover:no-underline'"
                        :href="link.url + (query ? '&' + query : '')"
                        v-html="link.label"
                    />
                </template>

                <template v-else-if='key === links.length - 1'>
                    <!-- Next -->
                    <Link
                        class="block p-2 ml-2 leading-tight bg-neutral-500 text-white dark:bg-neutral-500 dark:text-white rounded-full hover:bg-neutral-400 dark:hover:bg-neutral-400"
                        :href="link.url ? link.url + (query ? '&' + query : '') : '#'"
                    >
                        <span class="sr-only">Next</span>
                        <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="4" stroke="currentColor" class="w-3 h-3">
                            <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75" />
                        </svg>
                    </Link>
                </template>
            </li>
        </ul>
    </div>
</template>
```

# ページネーションコンポーネントの呼び出し

作成したページネーションコンポーネントは下記のようにして一覧画面で呼び出せます。
```vue
<Paginate class="mt-6" :links="items.links" :query="query" />
```

一覧画面のサンプルがこちら。

```resources/ts/Pages/Staff/List.vue
<script setup>
const props = defineProps({
    items: {
        type: Object,
    },
    query: {
        type: Array
    }
});
</script>

<template>
    <template #header>
        <h2 class="font-semibold text-3xl text-gray-800 leading-tight">スタッフ一覧</h2>
    </template>

    <div class="py-8">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8 text-center">
            <div class="relative overflow-x-auto shadow-md">
                <table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
                    <thead class="text-xs text-white uppercase bg-neutral-500 dark:bg-neutral-500 dark:text-white">
                        <tr>
                            <th scope="col" class="px-6 py-3 border-b border-white">
                                ID
                            </th>
                            <th scope="col" class="px-6 py-3 border-b border-white">
                                氏名
                            </th>
                            <th scope="col" class="px-6 py-3 border-b border-white">
                                メールアドレス
                            </th>
                            <th scope="col" class="px-6 py-3 border-b border-white">
                                作成日
                            </th>
                        </tr>
                    </thead>
                    <tbody class="text-neutral-500">
                        <tr v-for="(item, key) in items.data" :key="key" class="border-b border-white" :class="[key%2 === 0 ? 'bg-neutral-100' : 'bg-neutral-200']">
                            <td class="px-6 py-4 text-center">
                                {{ item.id }}
                            </td>
                            <td class="px-6 py-4 text-center">
                                {{ item.name }}
                            </td>
                            <td class="px-6 py-4 text-center">
                                {{ item.email }}
                            </td>
                            <td class="px-6 py-4 text-center">
                                {{ item.created_at }}
                            </td>
                        </tr>
                    </tbody>
                </table>
            </div>

            <div class="mt-10">
                <Paginate class="mt-6" :links="items.links" :query="query" />
            </div>

        </div>
    </div>
</template>
```

無事、ページャーの表示ができました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186115/4822f77a-e6c7-fb9d-6713-b2d551aa468c.png)

# おわりに
色々なサイトを参考にして実装したのですが、あちこち見すぎて参考サイトが見つけられなくなってしまったので、今回備忘録として残しました。