SvelteKit -BEから取得したデータ一覧をskeletonのデータテーブルで表示する

今回はSvelteKitでBE(FastAPI)から取得したデータ一覧をデータテーブルを表示する方法です。
# 環境
<ul>
    <li>Node.js 22.2 (Docker)</li>
    <li>python 3.9 (Docker)</li>
    <li>mysql 8.0 (Docker)</li>
</ul>

# 前提
<ul>
    <li>SvelteKitとFastAPIでDocker上で連携することができている</li>
    （今回はDocker上で動作させますが、ローカルでも構いません。）
    <li>Skeletonを導入済み</li>
    <li>FastAPIでCRUD処理を実装できる</li>
</ul>

※DBには以下のように、画面に表示したいユーザーデータ一覧が入っていることとします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3484672/97a0b2c6-e4a3-e332-3484-746e05e3ae00.png)


# 目標
今回はDBに登録してあるユーザーデータ一覧を取得して画面に表示したいと思います。
手順は以下の通りです。
<ol>
<li>FastAPIでDBからデータ一覧を取得するAPIの作成←既にできていることとして進めます</li>
<li>SvelteKitでfetchしてデータ一覧を取得する</li>
<li>Skeletonのデータテーブルを使用していい感じにする</li>
</ol>

 # FastAPIでDBからデータ一覧を取得するAPIの作成
 この記事ではSvelteKit部分に着目していくので、実装方法については解説せず、
 既に作成済みのものを使用します。
 実装方法についてはこちらの記事が参考になります。

https://zenn.dev/sh0nk/books/537bb028709ab9/viewer/b92ab0

作成したAPIをSwagger UIで確認するとこんな感じです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3484672/dccfbf3c-ce6f-a4a5-9d58-96389b0f5a11.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3484672/b1c0c2f8-bb2a-b35f-d5fd-03bcb552fc5a.png)

パラメーターなしだとユーザーを全件取得し、deptCを与えると、一致するdeptCを持ったユーザーのみ取得します。

# SvelteKitでfetchしてデータ一覧を取得する
いよいよ本題のSvelteKitの説明に入ります！
ここからはなるべく丁寧に説明して行きたいと思います！

FrontEnd側の構成は以下のような感じです。
```terminal
root
├src
| ├ lib
| |  ├ components
| |    ├ Datatable.svelte
| |
| ├ routes
| |  ├ UserList ←データテーブルをインポートして表示する画面
| |  |  ├ +page.server.ts
| |  |  ├ +page.svelte
| |  |  ├ userInterface.ts
| |  |
| | ├ +layout.svelte
| | ├ +page.server.ts
| | ├ +page.svelte
| |
├ .env ←BEへの接続先情報を保持
```

:::note
Datatableはcomponentとして、ビュー機能のみ保持します。
一覧のデータやテーブルの列などのDataTableの構成要素は、UserList/+page.server.tsで取得して渡すようにし,汎用性のあるコンポーネント作成を目指します。
:::

## ステップ1：　環境情報作成
まずは、プロジェクトルート直下に.envファイルを作成しましょう。
接続先情報として、BE_ENDPOINT作成しました。
ここは各自データテーブルに使用する一覧を取得するAPIの接続情報を設定して下さい。
```.env
BE_ENDPOINT=http://demo-app:8000
```
:::note
.envファイルで設定した環境情報は、サーバーサイドでのみ読み取り可能です。
:::
## ステップ2　BEからデータを取得した際のデータの型を作成
src/routes/UserList/userInterface.tsを作成する
```userInterface.ts
export interface UserInterface {
    username: string;
    user_id: number;
    email: string;
    deptC: string;
}
```
## ステップ3　サーバーサイドでfetchしてデータ一覧を取得する
src/routes/UserList/+page.server.tsを作成する。
単純なfetchでGetリクエストを送る書き方となっています。

```+page.server.ts
import { BE_ENDPOINT } from "$env/static/private";
import { error } from "@sveltejs/kit";
import type { UserInterface } from "./userInterface.js";

export async function load(){
    let url = BE_ENDPOINT + '/users';
    const res = await fetch(url);
    if (res.ok){
        let userList = await res.json() as UserInterface[];
        return {userList: userList};
    } else {
        throw error(404);
    }
}
```
とりあえず、正常にデータが取得できているか確かめてみます。
src/routes/UserList/+page.svelteを作成して、一覧を画面に表示してみます。

```svelte
<!-- +page.svelte -->
<script lang="ts">
    export let data;
    let userList = data.userList;
</script>

<ul>
    {#each userList as user}
        <li>name:{user.username}</li>
    {/each}
</ul>
```
いい感じに取得できていそうですね:ok_hand:
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3484672/22682e37-0c16-cd22-c6fa-2f70b4c9b6b3.png)

# Skeletonのデータテーブルを使用していい感じにする
いよいよコンポーネントの作成に入ります。
とりあえずは公式ドキュメントに従って、進めてみます。

https://www.skeleton.dev/docs/ssd

まずは、Svelte Simple Datablesをプロジェクトに追加します。
```terminal
npm i @vincjo/datatables
```
次にコンポーネントを作成していくのですが、
公式には、"ClientーBased"かSvelteKit SSRを使った、"Server-Based"があります。
まずは、"Client-Based"で作成してみようと思います。
src/lib/components/Datatable.svelteを作成します。
```svelte
<!-- Datatable.svelte -->
<script lang="ts">
    import { DataHandler } from "@vincjo/datatables";
    import type { UserInterface } from "../../routes/UserList/userInterface";
    export let data: UserInterface[];
    const handler = new DataHandler(data, {rowsPerPage: 5});
    const rows = handler.getRows();
</script>


<div class="table-container space-y-4">
    <table class="table table-hover table-compact table-auto w-full">
        <thead>
            <tr>
                <td>名前</td>
                <td>Email</td>
                <td>DeptC</td>
            </tr>
        </thead>
        <tbody>
            {#each $rows as row}
                <tr>
                    <td>{row.username}</td>
                    <td>{row.email}</td>
                    <td>{row.deptC}</td>
                </tr>
            {/each}
        </tbody>
    </table>
</div>
```
公式と違うポイントは、dataはインポート先でBEからfetchした値を使用するために、
プロップスとして使用しています。
早速ユーザー一覧ページにインポートして使ってみます！
/src/routes/UserList/+page.svelteを以下のように修正して下さい。
```svelte
<!-- +page.svelte -->
<script lang="ts">
    import type { UserInterface } from './userInterface.js';
    import Datatable from '$lib/components/Datatable.svelte';
    export let data: {userList: UserInterface[]};
    let userList: UserInterface[] = [];
    userList = data.userList;
</script>
<h1>ユーザー一覧</h1>
<div style="width: 70%;">
    <Datatable
        data={userList}
    />
</div>
```
※こんなに型定義をしなくてもできると思ったのですが、
typescriptのエラーが消えなくてたくさん書いちゃってます:sweat_smile:
ポイントは、インポートしたDatatableのプロップスにuserListを渡しているところです。

実行結果を見てみるとこんな感じです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3484672/8c9258b4-25b1-d9ac-8d54-ac2909d31dce.png)
とりあえず形になりました！
デフォルトで縞模様の行になってますね。
そして表示されているのは、5件のみとなっています。
これはDatatable.svelteで
`const handler = new DataHandler(data, {rowsPerPage: 5});と指定していたからです。

## Accessory Componentsの作成
次にAccessory Components(付属部品)を作成していきましょう。
公式では、以下のコンポーネントをDataTableコンポーネントに追加しています。
```ts
import Search from '$lib/components/Search.svelte';
import ThFilter from '$lib/components/ThFilter.svelte';
import ThSort from '$lib/components/ThSort.svelte';
import RowCount from '$lib/components/RowCount.svelte';
import RowsPerPage from '$lib/components/RowsPerPage.svelte';
import Pagination from '$lib/components/Pagination.svelte';
```     
もちろんこれらのコンポーネントは未作成なので、一つずつ作成していきます。
めんどくさい人はgithubからコピーしちゃって下さい。
ここでは一つずつ付け足して処理と動きを追っていきます。

https://github.com/skeletonlabs/skeleton-datatables-integration/tree/main/src/lib/components/client
### Search（検索）
以下のように/src/lib/components/Search.svelteを作成して下さい。
```svelte
<!-- Search.svelte -->
<script lang="ts">
	import type { DataHandler } from '@vincjo/datatables';
	export let handler: DataHandler;
	let value: string;
</script>

<input
	class="input sm:w-64 w-36"
	type="search"
	placeholder="Search..."
	bind:value
	on:input={() => handler.search(value)}
/>
```
そしてDataTableコンポーネントに取り付けます。
※コードの解説は後ほどします
```diff_svelte
<!-- +Datatable.svelte -->
<script lang="ts">
    import { DataHandler } from "@vincjo/datatables";
    import type { UserInterface } from "../../routes/UserList/userInterface";
+    import Search from "./Search.svelte";
    export let data: UserInterface[];
    const handler = new DataHandler(data, {rowsPerPage: 5});
    const rows = handler.getRows();
</script>


<div class="table-container space-y-4">
+    <header class="flex justify-between gap-4">
+       <Search {handler} />
+    </header>
    <table class="table table-hover table-compact table-auto w-full">
        <thead>
            <tr>
                <td>名前</td>
                <td>Email</td>
                <td>DeptC</td>
            </tr>
        </thead>
        <tbody>
            {#each $rows as row}
                <tr>
                    <td>{row.username}</td>
                    <td>{row.email}</td>
                    <td>{row.deptC}</td>
                </tr>
            {/each}
        </tbody>
    </table>
</div>
```
実行結果です。
![画面収録 2024-08-09 2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3484672/24ed9271-2a05-329b-5cc4-c2272c486831.gif)

Search.svelteの
`on:input={() => handler.search(value)}`で
入力された値によってテーブルの１列目の"名前"が曖昧一致で検索されていますね。
`handler.search`はどのような処理なのでしょうか？
@vincjo/datatablesをGithubで確認したところ、`handler.search`は以下のようになっていました。
```svelte
    public search(value: string, scope: Field<T>[] = null)
    {
        this.searchHandler.set(value, scope)
    }
```
サンプルコードでは引数はvalueのみでscopeは省略していたので、nullを渡していたということですね。
もしも検索する列を指定したい場合は、以下のようにすることで、列を指定したり、複数の列を検索対象にすることができます。

Search.svelte
```diff_svelte
<script lang="ts">
	import type { DataHandler, Field } from '@vincjo/datatables';
	export let handler: DataHandler;
+    export let searchFields: Field<any>[];
	let value: string;
</script>

<input
	class="input sm:w-64 w-36"
	type="search"
	placeholder="Search..."
	bind:value
+	on:input={() => handler.search(value, searchFields)}
/>
```
Datatable.svelte
```diff_svelte
...
<div class="table-container space-y-4">
    <header class="flex justify-between gap-4">
+        <Search {handler} searchFields={["username","deptC"]} /> ←検索対象にしたい列を指定する
    </header>
...
```

### ThFilter(フィルター)　とThSort(ソート)
続いて、フィルターとソートを実装します。
まずは、各列でフィルターを設定できるようにしましょう。
（これは、ほぼSearchコンポーネントと同じですね）
以下のようにsrc/lib/components/ThFilter.svelteを作成して下さい。
```svelte
<script lang="ts">
	import type { DataHandler } from '@vincjo/datatables';
	export let handler: DataHandler;
	export let filterBy: string;
	let value: string;
</script>

<th>
	<input
		class="input text-sm w-full"
		type="text"
		placeholder="Filter"
		bind:value
		on:input={() => {
			if (filterBy) handler.filter(value, filterBy);
		}}
	/>
</th>
```
次にソートです。
以下のようにsrc/lib/components/TheFilter.svelteを作成して下さい。
```svelte
<script lang="ts">
	import type { DataHandler } from '@vincjo/datatables';

	export let handler: DataHandler;
	export let orderBy: string;

	const sorted = handler.getSort();
</script>

<th on:click={() => handler.sort(orderBy)} class="cursor-pointer select-none">
	<div class="flex h-full items-center justify-start gap-x-2">
		<slot />
		{#if $sorted.identifier === orderBy}
			{#if $sorted.direction === 'asc'}
				&darr;
			{:else if $sorted.direction === 'desc'}
				&uarr;
			{/if}
		{:else}
			&updownarrow;
		{/if}
	</div>
</th>
```
そして,DataTableコンポーネントに取り付けます。
```diff_svelte
<script lang="ts">
    import { DataHandler } from "@vincjo/datatables";
    import type { UserInterface } from "../../routes/UserList/userInterface";
    import Search from "./Search.svelte";
+    import ThSort from "./ThSort.svelte";
+    import ThFilter from "./ThFilter.svelte";
    export let data: UserInterface[];
    const handler = new DataHandler(data, {rowsPerPage: 5});
    const rows = handler.getRows();
</script>


<div class="table-container space-y-4">
    <header class="flex justify-between gap-4">
        <Search {handler} searchFields={["username"]} />
    </header>
    <table class="table table-hover table-compact table-auto w-full">
+        <thead>
+            <tr>
+                <ThSort {handler} orderBy="username">名前</ThSort>
+                <ThSort {handler} orderBy="email">Email</ThSort>
+                <ThSort {handler} orderBy="deptC">部署C</ThSort>
+            </tr>
+            <tr>
+                <ThFilter {handler} filterBy="username" />
+                <ThFilter {handler} filterBy="email" />
+                <ThFilter {handler} filterBy="deptC" />
+            </tr>
+        </thead>
        <tbody>
            {#each $rows as row}
                <tr>
                    <td>{row.username}</td>
                    <td>{row.email}</td>
                    <td>{row.deptC}</td>
                </tr>
            {/each}
        </tbody>
    </table>
</div>
```
ここまでで大体理解したと思いますが、公式だったり、githubのソースをコピーしてとりあえず、お手本通りのものを作成しています。分からなかったら公式を見ましょう！
それでは一旦ここまでで画面の動きを確認します。
![画面収録 2024-08-11 22.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3484672/528acb0c-3887-8a18-30c4-a9b483d358ff.gif)
いい感じですね:v:

### RowCount(件数) & RowsPerPage(表示件数）& Pagenation(ページ遷移）
次は一気に3つ実装します。これで見本と同じになるはずです。
これまでと同じく、src/lib/componentsの配下にRowCount.svelte、RowsPerPage.svelte、Pagenation.svelteの3つを作成します。
まずは、RowCount.svelteです。
```svelte
<script lang="ts">
    import type { DataHandler } from "@vincjo/datatables";

    type T = $$Generic<Row>

    export let handler: DataHandler<T>
    export let small = false
    const rowCount = handler.getRowCount()
</script>

<aside class={$$props.class ?? ''}>
    {#if small}
        {#if $rowCount.total > 0}
            <b>{$rowCount.start}</b>-
            <b>{$rowCount.end}</b>/
            <b>{$rowCount.total}</b>
        {:else}
            {handler.i18n.noRows}
        {/if}
    {:else if $rowCount.total > 0}
        {#if handler.i18n.rowCount}
            {@html handler.i18n.rowCount
                .replace('{start}', `<b>${$rowCount.start}</b>`)
                .replace('{end}', `<b>${$rowCount.end}</b>`)
                .replace('{total}', `<b>${$rowCount.total}</b>`)}
        {:else}
                {handler.i18n.noRows ?? "No rows"}
        {/if}
    {:else}
        {handler.i18n.noRows}
    {/if}
</aside>

<style>
    aside {
        color: #616161;
        line-height: 32px;
        font-size: 14px;
    }
</style>
```
RowCount.svelteで気になったのは以下の部分です。
```svelte
{#if handler.i18n.rowCount}
            {@html handler.i18n.rowCount
                .replace('{start}', `<b>${$rowCount.start}</b>`)
                .replace('{end}', `<b>${$rowCount.end}</b>`)
                .replace('{total}', `<b>${$rowCount.total}</b>`)}
```
まず、handler.i18nってなんでしょうか？:thinking:
型確認すると、internationalization型となっていて、
githubでDatahandler.tsを確認すると以下のようにセットしていました。
```typescript
    public translate(i18n: Internationalization): Internationalization
    {
        return {
            ...{
                search: 'Search...',
                show: 'Show',
                entries: 'entries',
                filter: 'Filter',
                rowCount: 'Showing {start} to {end} of {total} entries',
                noRows: 'No entries found',
                previous: 'Previous',
                next: 'Next'
            },
            ...i18n
        }
    }
```
つまり、データ数>0の時、
rowCountの文字列の{start}{end}{total}をそれぞれreplaceして表示するということですね。

### RowsPerPage.svelte
次にRowsPerPage.svelteです。
```svelte
<script lang="ts">
    import type { DataHandler } from "@vincjo/datatables";

    type T = $$Generic<Row>

    export let handler: DataHandler<T>
    export let small = false

    const rowsPerPage = handler.getRowsPerPage()

    const options = [5, 10, 20, 50, 100]
</script>

<aside class={$$props.class ?? ''}>
    {#if !small}
        <span>{handler.i18n.show}</span>
    {/if}
    <select bind:value={$rowsPerPage} on:change={() => handler.setPage(1)}>
        {#each options as option}
            <option value={option}>
                {option}
            </option>
        {/each}
    </select>
    {#if !small}
        <span>{handler.i18n.entries}</span>
    {/if}
</aside>

<style>
    aside {
        display: flex;
        justify-content: flex-start;
        align-items: center;
        height: 32px;
        color: #757575;
    }
    select {
        margin: 0 4px;
    }
</style>
```
`on:change={() => handler.setPage(1)}`
は、セレクトボックスの値を変更して、1ページの表示件数を変更した時に、
1ページ目を表示するようにしているようです。

### Pagination.svelte
最後は、Pagination.svelteを作成します。
```svelte
<script lang="ts">
	import type { DataHandler } from '@vincjo/datatables';
	export let handler: DataHandler;
	const pageNumber = handler.getPageNumber();
	const pageCount = handler.getPageCount();
	const pages = handler.getPages({ ellipsis: true });
</script>

<!-- Desktop buttons -->
<section class="btn-group variant-ghost-surface [&>*+*]:border-surface-500 h-10 hidden lg:block">
	<button
		type="button"
		class="hover:variant-soft-primary"
		class:disabled={$pageNumber === 1}
		on:click={() => handler.setPage('previous')}
	>
		←
	</button>
	{#each $pages as page}
		<button
			type="button"
			class="hover:variant-soft-primary"
			class:active={$pageNumber === page}
			class:ellipse={page === null}
			on:click={() => handler.setPage(page)}
		>
			{page ?? '...'}
		</button>
	{/each}
	<button
		type="button"
		class="hover:variant-soft-primary"
		class:disabled={$pageNumber === $pageCount}
		on:click={() => handler.setPage('next')}
	>
		→
	</button>
</section>

<!-- Mobile buttons -->
<section class="lg:hidden">
	<button
		type="button"
		class="btn variant-ghost-surface mr-2 mb-2 hover:variant-soft-primary"
		class:disabled={$pageNumber === 1}
		on:click={() => handler.setPage('previous')}
	>
		←
	</button>
	<button
		type="button"
		class="btn variant-ghost-surface mb-2 hover:variant-soft-primary"
		class:disabled={$pageNumber === $pageCount}
		on:click={() => handler.setPage('next')}
	>
		→
	</button>
</section>
```
ここでは、
`handler.setPage('previous')`と
`handler.setPage('next`)があります。
その名の通り、`'previous'`だと1ページ前を表示
`'next'`だと次のページを表示します。

### DataTable.svelte
それでは、DataTable.svelteを修正して、作成したコンポーネントを取り付けていきます。
```diff_svelte
<script lang="ts">
    import { DataHandler } from "@vincjo/datatables";
    import type { UserInterface } from "../../routes/UserList/userInterface";
    import Search from "./Search.svelte";
    import ThSort from "./ThSort.svelte";
    import ThFilter from "./ThFilter.svelte";
+    import Pagination from "./Pagination.svelte";
+    import RowCount from "./RowCount.svelte";
+    import RowsPerPage from "./RowsPerPage.svelte";
    export let data: UserInterface[];
    const handler = new DataHandler(data, {rowsPerPage: 5});
    const rows = handler.getRows();
</script>


<div class="table-container space-y-4">
    <header class="flex justify-between gap-4">
        <Search {handler} searchFields={["username","deptC"]} />
+        <RowsPerPage {handler} />
    </header>
    <table class="table table-hover table-compact table-auto w-full">
        <thead>
            <tr>
                <ThSort {handler} orderBy="username">名前</ThSort>
                <ThSort {handler} orderBy="email">Email</ThSort>
                <ThSort {handler} orderBy="deptC">部署C</ThSort>
            </tr>
            <tr>
                <ThFilter {handler} filterBy="username" />
                <ThFilter {handler} filterBy="email" />
                <ThFilter {handler} filterBy="deptC" />
            </tr>
        </thead>
        <tbody>
            {#each $rows as row}
                <tr>
                    <td>{row.username}</td>
                    <td>{row.email}</td>
                    <td>{row.deptC}</td>
                </tr>
            {/each}
        </tbody>
    </table>
+    <footer class="flex justify-between">
+        <RowCount {handler} />
+        <Pagination {handler} />
+    </footer>
</div>
```
以下は実行結果です。
![画面収録 2024-08-12 23.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3484672/2be384ee-a44c-3c3b-aae9-f98bdd4574de.gif)
これでとりあえず、見本通りのテーブルを作成できました！
# 終わりに
今回は学習用に紆余曲折を挟んでゴールへと辿り着きましたが、
公式のコードをコピーするだけでいいものができたと思います。
次回は、わかりやすく使い方をまとめつつ、使いやすく修正したバージョンを投稿をしたいと思います。

::: note 
もしわからないところがあれば、公式ページを見たり、gituhubのソースを追っていくと良いと思います。
:::