Changes
8 changed files (+384/-0)
-
-
@@ -0,0 +1,42 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type Decorator } from "@storybook/react"; import { type ReactElement } from "react"; import * as NavigationMenu from "../../src/components/NavigationMenu.ts"; import { WorkspacePagesLayout } from "../../src/components/WorkspacePagesLayout.tsx"; export interface WithWorkspacePagesLayoutOptions { title?: ReactElement; actions?: ReactElement; menu?: ReactElement; } export function withWorkspacePagesLayout<Args>({ title = <>タイトル未設定</>, actions, menu = ( <NavigationMenu.Root> <NavigationMenu.Group title="グループ"> <NavigationMenu.Item> <button>アイテム1</button> </NavigationMenu.Item> <NavigationMenu.Item current> <button>アイテム2</button> </NavigationMenu.Item> <NavigationMenu.Item> <button>アイテム3</button> </NavigationMenu.Item> </NavigationMenu.Group> </NavigationMenu.Root> ), }: WithWorkspacePagesLayoutOptions = {}): Decorator<Args> { return (Story) => { return ( <WorkspacePagesLayout title={title} actions={actions} menu={menu}> <Story /> </WorkspacePagesLayout> ); }; }
-
-
-
@@ -0,0 +1,80 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../.storybook/decorators/withMockedBackend.tsx"; import { withWorkspacePagesLayout } from "../../.storybook/decorators/withWorkspacePagesLayout.tsx"; import { List } from "../mocks/yamori/worker/v1/worker_service.ts"; import * as WorkerListPage from "./WorkerListPage.ts"; const workspace = create(WorkspaceSchema, { id: { value: "ws-foo" }, }); export default { component: WorkerListPage.Page, parameters: { layout: "fullscreen", }, args: { workspace, createButton: <button onClick={action("onCreate")}>登録画面へ</button>, }, } satisfies Meta<(typeof WorkerListPage)["Page"]>; type Story = StoryObj<(typeof WorkerListPage)["Page"]>; export const Success: Story = { decorators: [ withWorkspacePagesLayout({ title: <WorkerListPage.Title />, actions: <WorkerListPage.Actions workspace={workspace} />, }), withMockedBackend([List({ workspace })]), ], }; export const SlowLoad: Story = { decorators: [ withWorkspacePagesLayout({ title: <WorkerListPage.Title />, actions: <WorkerListPage.Actions workspace={workspace} />, }), withMockedBackend([List({ workspace, delayMs: 2_000 })]), ], }; export const NoWorkers: Story = { decorators: [ withWorkspacePagesLayout({ title: <WorkerListPage.Title />, actions: <WorkerListPage.Actions workspace={workspace} />, }), withMockedBackend([List({ workspace, workers: [] })]), ], }; export const ListLoadError: Story = { decorators: [ withWorkspacePagesLayout({ title: <WorkerListPage.Title />, actions: <WorkerListPage.Actions workspace={workspace} />, }), withMockedBackend([List({ workspace, failureRate: 1 })]), ], }; export const Flaky: Story = { decorators: [ withWorkspacePagesLayout({ title: <WorkerListPage.Title />, actions: <WorkerListPage.Actions workspace={workspace} />, }), withMockedBackend([List({ workspace, failureRate: 0.5 })]), ], };
-
-
-
@@ -0,0 +1,4 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only export * from "./WorkerListPage/Page.tsx";
-
-
-
@@ -0,0 +1,53 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { Flex, Separator } from "@radix-ui/themes"; import { type Worker, WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { type FC, Fragment } from "react"; import { Row } from "./Row.tsx"; export interface LoadingListProps { workers?: undefined; loading: true; } export interface LoadedListProps { workers: readonly Worker[]; loading?: false; } export type ListProps = LoadingListProps | LoadedListProps; const SKELETON_WORKERS: readonly Worker[] = [ create(WorkerSchema, { displayName: "あああ いい", }), create(WorkerSchema, { displayName: "ああ いい", }), create(WorkerSchema, { displayName: "あ いいいいい", }), ]; export const List: FC<ListProps> = ({ workers, loading }) => { return ( <Flex direction="column" gap="3"> {loading ? SKELETON_WORKERS.map((worker, i) => ( <Fragment key={i}> {i > 0 && <Separator size="4" />} <Row loading worker={worker} /> </Fragment> )) : workers.map((worker, i) => ( <Fragment key={worker.id?.value}> {i > 0 && <Separator size="4" />} <Row worker={worker} /> </Fragment> ))} </Flex> ); };
-
-
-
@@ -0,0 +1,101 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Button, Flex, Heading, Text } from "@radix-ui/themes"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { ListRequestSchema } from "@yamori/proto/yamori/worker/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/worker/v1/list_response_pb.js"; import { type FC, type ReactElement } from "react"; import { useMethodQuery } from "../../contexts/Service.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import { ManagedErrorCallout } from "../ErrorCallout.ts"; import { List } from "./List.tsx"; function useWorkerList(workspace: Workspace) { return useMethodQuery({ service: "yamori.worker.v1.WorkerService", method: "List", request: { schema: ListRequestSchema, data: { workspaceId: workspace.id!, }, }, response: { schema: ListResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, }); } export interface PageProps { workspace: Workspace; createButton: ReactElement; } export const Page: FC<PageProps> = ({ workspace, createButton }) => { const list = useWorkerList(workspace); if (list.fetchStatus === "idle" && list.data?.workers.length === 0) { return ( <Flex mt="7" direction="column" align="center" gap="3"> <Heading as="h2" size="4"> 労働者が登録されていません </Heading> <Text as="p" size="2" align="center"> ワークスペース内に労働者がまだ一人も登録されていません。 </Text> <Button asChild mt="4" size="3"> {createButton} </Button> </Flex> ); } return ( <Flex direction="column" mt="5" gap="5"> {!!list.error && ( <ManagedErrorCallout severity={list.data ? "warning" : "danger"} title={list.data ? "一覧の更新に失敗しました" : "一覧の読込に失敗しました"} error={list.error} actions={ <Button size="1" loading={list.isFetching} onClick={() => void list.refetch()} > {list.data ? "再試行" : "再取得"} </Button> } /> )} {!list.data ? <List loading /> : <List workers={list.data.workers} />} </Flex> ); }; export const Title: FC = () => "労働者一覧"; export const Actions: FC<Pick<PageProps, "workspace">> = ({ workspace }) => { const list = useWorkerList(workspace); return ( <Button variant="soft" loading={list.isFetching} onClick={() => void list.refetch()}> 更新 </Button> ); };
-
-
-
@@ -0,0 +1,28 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { PersonIcon } from "@radix-ui/react-icons"; import { Avatar, Flex, Text, Skeleton } from "@radix-ui/themes"; import { type Worker } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { type FC } from "react"; export interface RowProps { worker: Worker; loading?: boolean; } export const Row: FC<RowProps> = ({ worker, loading = false }) => { return ( <Flex align="center" gap="2"> <Skeleton loading={loading}> <Avatar fallback={worker.displayName[0] || <PersonIcon />} /> </Skeleton> <Skeleton loading={loading}> <Text color={worker.displayName ? undefined : "gray"}> {worker.displayName || "名称未設定"} </Text> </Skeleton> </Flex> ); };
-
-
-
@@ -5,5 +5,7 @@ import "@radix-ui/themes/styles.css";export * from "./components/ThemeProvider.tsx"; export * from "./components/WorkspaceSelectionPage.tsx"; export * from "./components/WorkerListPage.ts"; export * from "./components/WorkspacePagesLayout.tsx"; export * from "./contexts/Service.tsx";
-
-
-
@@ -0,0 +1,74 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type MessageInitShape } from "@bufbuild/protobuf"; import { WorkerService } from "@yamori/proto/yamori/worker/v1/worker_service_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { mock, type MockOptions, } from "../../../../../.storybook/decorators/withMockedBackend.tsx"; export interface ListOptions extends MockOptions { workspace?: MessageInitShape<typeof WorkspaceSchema>; workers?: MessageInitShape<typeof WorkerSchema>[]; failureRate?: number; } export function List({ workspace = { id: { value: "ws-foo", }, }, workers = [ { id: { value: "wr-foo", }, displayName: "日本 太郎", }, { id: { value: "wr-bar", }, displayName: "行政 花子", }, { id: { value: "wr-baz", }, }, ], failureRate = 0, ...options }: ListOptions = {}) { return mock( WorkerService.method.list, () => { if (Math.random() < failureRate) { return { result: { case: "systemError" as const, value: { code: "MOCK_ERR", message: "This is sample error message.", }, }, }; } return { result: { case: "ok" as const, value: { workers, workspaceId: workspace.id! }, }, }; }, options, ); }
-