今回はSvelteKitでBE(FastAPI)から取得したデータ一覧をデータテーブルを表示する方法です。
環境
- Node.js 22.2 (Docker)
- python 3.9 (Docker)
- mysql 8.0 (Docker)
前提
- SvelteKitとFastAPIでDocker上で連携することができている (今回はDocker上で動作させますが、ローカルでも構いません。)
- Skeletonを導入済み
- FastAPIでCRUD処理を実装できる
※DBには以下のように、画面に表示したいユーザーデータ一覧が入っていることとします。
目標
今回はDBに登録してあるユーザーデータ一覧を取得して画面に表示したいと思います。
手順は以下の通りです。
- FastAPIでDBからデータ一覧を取得するAPIの作成←既にできていることとして進めます
- SvelteKitでfetchしてデータ一覧を取得する
- Skeletonのデータテーブルを使用していい感じにする
FastAPIでDBからデータ一覧を取得するAPIの作成
この記事ではSvelteKit部分に着目していくので、実装方法については解説せず、
既に作成済みのものを使用します。
実装方法についてはこちらの記事が参考になります。
作成したAPIをSwagger UIで確認するとこんな感じです。
パラメーターなしだとユーザーを全件取得し、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の接続情報を設定して下さい。
BE_ENDPOINT=http://demo-app:8000
.envファイルで設定した環境情報は、サーバーサイドでのみ読み取り可能です。
ステップ2 BEからデータを取得した際のデータの型を作成
src/routes/UserList/userInterface.tsを作成する
export interface UserInterface {
username: string;
user_id: number;
email: string;
deptC: string;
}
ステップ3 サーバーサイドでfetchしてデータ一覧を取得する
src/routes/UserList/+page.server.tsを作成する。
単純なfetchでGetリクエストを送る書き方となっています。
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>
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のエラーが消えなくてたくさん書いちゃってます
ポイントは、インポートしたDatatableのプロップスにuserListを渡しているところです。
実行結果を見てみるとこんな感じです。
とりあえず形になりました!
デフォルトで縞模様の行になってますね。
そして表示されているのは、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>
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'}
↓
{:else if $sorted.direction === 'desc'}
↑
{/if}
{:else}
↕
{/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のソースをコピーしてとりあえず、お手本通りのものを作成しています。分からなかったら公式を見ましょう!
それでは一旦ここまでで画面の動きを確認します。
いい感じですね
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ってなんでしょうか?
型確認すると、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>
以下は実行結果です。
これでとりあえず、見本通りのテーブルを作成できました!
終わりに
今回は学習用に紆余曲折を挟んでゴールへと辿り着きましたが、
公式のコードをコピーするだけでいいものができたと思います。
次回は、わかりやすく使い方をまとめつつ、使いやすく修正したバージョンを投稿をしたいと思います。
もしわからないところがあれば、公式ページを見たり、gituhubのソースを追っていくと良いと思います。