0
0

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

Posted at

今回はSvelteKitでBE(FastAPI)から取得したデータ一覧をデータテーブルを表示する方法です。

環境

  • Node.js 22.2 (Docker)
  • python 3.9 (Docker)
  • mysql 8.0 (Docker)

前提

  • SvelteKitとFastAPIでDocker上で連携することができている
  • (今回はDocker上で動作させますが、ローカルでも構いません。)
  • Skeletonを導入済み
  • FastAPIでCRUD処理を実装できる

※DBには以下のように、画面に表示したいユーザーデータ一覧が入っていることとします。
image.png

目標

今回はDBに登録してあるユーザーデータ一覧を取得して画面に表示したいと思います。
手順は以下の通りです。

  1. FastAPIでDBからデータ一覧を取得するAPIの作成←既にできていることとして進めます
  2. SvelteKitでfetchしてデータ一覧を取得する
  3. Skeletonのデータテーブルを使用していい感じにする

FastAPIでDBからデータ一覧を取得するAPIの作成

この記事ではSvelteKit部分に着目していくので、実装方法については解説せず、
既に作成済みのものを使用します。
実装方法についてはこちらの記事が参考になります。

作成したAPIをSwagger UIで確認するとこんな感じです。
image.png
image.png

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

SvelteKitでfetchしてデータ一覧を取得する

いよいよ本題のSvelteKitの説明に入ります!
ここからはなるべく丁寧に説明して行きたいと思います!

FrontEnd側の構成は以下のような感じです。

root
├src
| ├ lib
| |  ├ components
| |    ├ Datatable.svelte
| |
| ├ routes
| |  ├ UserList ←データテーブルをインポートして表示する画面
| |  |  ├ +page.server.ts
| |  |  ├ +page.svelte
| |  |  ├ userInterface.ts
| |  |
| | ├ +layout.svelte
| | ├ +page.server.ts
| | ├ +page.svelte
| |
├ .env ←BEへの接続先情報を保持

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

ステップ1: 環境情報作成

まずは、プロジェクトルート直下に.envファイルを作成しましょう。
接続先情報として、BE_ENDPOINT作成しました。
ここは各自データテーブルに使用する一覧を取得するAPIの接続情報を設定して下さい。

.env
BE_ENDPOINT=http://demo-app:8000

.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を作成して、一覧を画面に表示してみます。

<!-- +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

Skeletonのデータテーブルを使用していい感じにする

いよいよコンポーネントの作成に入ります。
とりあえずは公式ドキュメントに従って、進めてみます。

まずは、Svelte Simple Datablesをプロジェクトに追加します。

npm i @vincjo/datatables

次にコンポーネントを作成していくのですが、
公式には、"ClientーBased"かSvelteKit SSRを使った、"Server-Based"があります。
まずは、"Client-Based"で作成してみようと思います。
src/lib/components/Datatable.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を以下のように修正して下さい。

<!-- +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
とりあえず形になりました!
デフォルトで縞模様の行になってますね。
そして表示されているのは、5件のみとなっています。
これはDatatable.svelteで
`const handler = new DataHandler(data, {rowsPerPage: 5});と指定していたからです。

Accessory Componentsの作成

次にAccessory Components(付属部品)を作成していきましょう。
公式では、以下のコンポーネントをDataTableコンポーネントに追加しています。

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からコピーしちゃって下さい。
ここでは一つずつ付け足して処理と動きを追っていきます。

Search(検索)

以下のように/src/lib/components/Search.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コンポーネントに取り付けます。
※コードの解説は後ほどします

<!-- +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

Search.svelteの
on:input={() => handler.search(value)}
入力された値によってテーブルの1列目の"名前"が曖昧一致で検索されていますね。
handler.searchはどのような処理なのでしょうか?
@vincjo/datatablesをGithubで確認したところ、handler.searchは以下のようになっていました。

    public search(value: string, scope: Field<T>[] = null)
    {
        this.searchHandler.set(value, scope)
    }

サンプルコードでは引数はvalueのみでscopeは省略していたので、nullを渡していたということですね。
もしも検索する列を指定したい場合は、以下のようにすることで、列を指定したり、複数の列を検索対象にすることができます。

Search.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

...
<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を作成して下さい。

<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を作成して下さい。

<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コンポーネントに取り付けます。

<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
いい感じですね:v:

RowCount(件数) & RowsPerPage(表示件数)& Pagenation(ページ遷移)

次は一気に3つ実装します。これで見本と同じになるはずです。
これまでと同じく、src/lib/componentsの配下にRowCount.svelte、RowsPerPage.svelte、Pagenation.svelteの3つを作成します。
まずは、RowCount.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で気になったのは以下の部分です。

{#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を確認すると以下のようにセットしていました。

    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です。

<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を作成します。

<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を修正して、作成したコンポーネントを取り付けていきます。

<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
これでとりあえず、見本通りのテーブルを作成できました!

終わりに

今回は学習用に紆余曲折を挟んでゴールへと辿り着きましたが、
公式のコードをコピーするだけでいいものができたと思います。
次回は、わかりやすく使い方をまとめつつ、使いやすく修正したバージョンを投稿をしたいと思います。

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0