Changes
14 changed files (+417/-240)
-
-
-
@@ -20,12 +20,16 @@ method: string;handler(request: Uint8Array): Promise<Uint8Array> | Uint8Array; } export interface MockOptions { delayMs?: number; } export function mock<Input extends DescMessage, Output extends DescMessage>( method: { input: Input; output: Output } & DescMethod, handler: ( request: MessageShape<Input>, ) => Promise<MessageInitShape<Output>> | MessageInitShape<Output>, { delayMs = 0 }: { delayMs?: number } = {}, { delayMs = 0 }: MockOptions = {}, ): MethodMock { return { service: method.parent.typeName,
-
-
-
@@ -18,7 +18,8 @@ "default": "./dist/styles.css"} }, "peerDependencies": { "react": "19.x.x" "react": "19.x.x", "react-dom": "19.x.x" }, "dependencies": { "@bufbuild/protobuf": "^2.2.2",
-
-
-
@@ -1,15 +1,13 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { ListResponseSchema } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js"; import { KeyValueStorageBasedWorkspaceService } from "@yamori/proto/yamori/workspace/v1/key_value_storage_based_workspace_service_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../.storybook/decorators/withMockedBackend.tsx"; import { withMockedBackend, mock, } from "../../.storybook/decorators/withMockedBackend.tsx"; Create, List, } from "../mocks/yamori/workspace/v1/key_value_storage_based_workspace_service.ts"; import { WorkspaceSelectionPage } from "./WorkspaceSelectionPage.tsx";
-
@@ -22,91 +20,29 @@ } satisfies Meta<typeof WorkspaceSelectionPage>;type Story = StoryObj<typeof WorkspaceSelectionPage>; const success = create(ListResponseSchema, { result: { case: "ok", value: { workspaces: [ { id: { value: "ws-foo" }, displayName: "株式会社あああ", }, { id: { value: "ws-bar" }, displayName: "有限会社いいい", }, { id: { value: "ws-baz" }, displayName: "NPO法人 ううう", }, ], }, }, }); export const Success: Story = { decorators: [ withMockedBackend([ mock(KeyValueStorageBasedWorkspaceService.method.list, () => success), ]), ], decorators: [withMockedBackend([List(), Create()])], }; export const SlowLoad: Story = { decorators: [ withMockedBackend([ mock(KeyValueStorageBasedWorkspaceService.method.list, () => success, { delayMs: 2_000, }), ]), ], decorators: [withMockedBackend([List({ delayMs: 2_000 }), Create({ delayMs: 1_000 })])], }; export const NoWorkspaces: Story = { decorators: [ withMockedBackend([ mock(KeyValueStorageBasedWorkspaceService.method.list, () => { return create(ListResponseSchema, { result: { case: "ok", value: { workspaces: [], }, }, }); }), ]), ], decorators: [withMockedBackend([List({ workspaces: [] }), Create()])], }; const systemError = create(ListResponseSchema, { result: { case: "systemError", value: { code: "SAMPLE_ERR", message: "This is sample error message", }, }, }); export const ListLoadError: Story = { decorators: [ withMockedBackend([ mock(KeyValueStorageBasedWorkspaceService.method.list, () => systemError, { delayMs: 200, }), ]), withMockedBackend([List({ failureRate: 1, delayMs: 200 }), Create({ delayMs: 200 })]), ], }; export const FlakyListLoading: Story = { export const Flaky: Story = { decorators: [ withMockedBackend([ mock( KeyValueStorageBasedWorkspaceService.method.list, () => (Math.random() >= 0.5 ? success : systemError), { delayMs: 200 }, ), List({ failureRate: 0.5, delayMs: 200 }), Create({ failureRate: 0.5, delayMs: 200 }), ]), ], };
-
-
-
@@ -1,100 +1,46 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Button, Flex, Spinner, Text } from "@radix-ui/themes"; import { ListRequestSchema } from "@yamori/proto/yamori/workspace/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js"; import { type FC } from "react"; import { type FC, useCallback, useState } from "react"; import { useMethodQuery } from "../contexts/Service.tsx"; import { IllegalMessageError } from "../errors/IllegalMessageError.ts"; import { ManagedErrorCallout } from "./ErrorCallout.ts"; import { useViewTransition } from "../hooks/useViewTransition.ts"; import { Layout } from "./WorkspaceSelectionPage/Layout.tsx"; import { WorkspaceList } from "./WorkspaceSelectionPage/WorkspaceList.tsx"; import { Selector } from "./WorkspaceSelectionPage/Selector.tsx"; import { CreationForm } from "./WorkspaceSelectionPage/CreationForm.tsx"; export interface WorkspaceSelectionPageProps { className?: string | undefined; } export const WorkspaceSelectionPage: FC<WorkspaceSelectionPageProps> = () => { const list = useMethodQuery({ service: "yamori.workspace.v1.KeyValueStorageBasedWorkspaceService", method: "List", request: { schema: ListRequestSchema, data: {}, }, response: { schema: ListResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value; case "systemError": throw resp.result.value; default: throw new IllegalMessageError(resp); } }, }); const viewTransition = useViewTransition(); if (list.status === "pending") { return ( <Layout title="ワークスペース選択"> <Flex align="center" justify="center" pt="7" gap="2"> <Spinner /> <Text size="2" color="gray"> ワークスペースを読込中... </Text> </Flex> </Layout> ); } const [isOpeningCreationForm, setIsOpeningCreationForm] = useState(false); if (!list.data) { const openCreationForm = useCallback(() => { viewTransition(() => { setIsOpeningCreationForm(true); }); }, []); const openList = useCallback(() => { viewTransition(() => { setIsOpeningCreationForm(false); }); }, []); if (isOpeningCreationForm) { return ( <Layout title="ワークスペース選択"> <ManagedErrorCallout title="一覧の取得に失敗しました" error={list.error} actions={ <Button size="1" loading={list.isFetching} onClick={() => void list.refetch()} > 再取得 </Button> } /> <Layout title="ワークスペース作成" onBack={openList}> <CreationForm onCreated={openList} /> </Layout> ); } return ( <Layout title="ワークスペース選択"> <Flex direction="column" gap="5"> {!!list.error && ( <ManagedErrorCallout severity="warning" title="最新の一覧の取得に失敗しました" error={list.error} actions={ <Button size="1" loading={list.isFetching} onClick={() => void list.refetch()} > 再試行 </Button> } /> )} <WorkspaceList workspaces={list.data.workspaces} /> </Flex> <Selector onOpenCreationForm={openCreationForm} /> </Layout> ); };
-
-
-
@@ -1,16 +1,11 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import type { Meta, StoryObj } from "@storybook/react"; import { type CreateRequest } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { KeyValueStorageBasedWorkspaceService } from "@yamori/proto/yamori/workspace/v1/key_value_storage_based_workspace_service_pb.js"; import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { withMockedBackend, mock, } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { Create } from "../../mocks/yamori/workspace/v1/key_value_storage_based_workspace_service.ts"; import { CreationForm } from "./CreationForm.tsx";
-
@@ -20,57 +15,27 @@ } satisfies Meta<typeof CreationForm>;type Story = StoryObj<typeof CreationForm>; function mockHandler(req: CreateRequest) { const id = "ws-" + crypto.randomUUID(); return create(CreateResponseSchema, { result: { case: "ok", value: { workspace: { id: { value: id }, displayName: req.displayName, }, }, }, }); } export const Success: Story = { decorators: [ withMockedBackend([ mock(KeyValueStorageBasedWorkspaceService.method.create, mockHandler), ]), ], decorators: [withMockedBackend([Create()])], }; export const SlowLoad: Story = { decorators: [ withMockedBackend([ mock(KeyValueStorageBasedWorkspaceService.method.create, mockHandler, { delayMs: 2_000, }), ]), ], decorators: [withMockedBackend([Create({ delayMs: 2_000 })])], }; export const MissingField: Story = { decorators: [ withMockedBackend([ mock( KeyValueStorageBasedWorkspaceService.method.create, () => { return create(CreateResponseSchema, { result: { case: "missingField", value: { path: "display_name", }, }, }); Create({ failureRate: 1, error: { case: "missingField", value: { path: "display_name", }, }, { delayMs: 1_000 }, ), delayMs: 1_000, }), ]), ], };
-
@@ -78,20 +43,16 @@export const NoStorageSpace: Story = { decorators: [ withMockedBackend([ mock( KeyValueStorageBasedWorkspaceService.method.create, () => { return create(CreateResponseSchema, { result: { case: "noStorageSpace", value: { message: "Test error", }, }, }); Create({ failureRate: 1, error: { case: "noStorageSpace", value: { message: "Test error", }, }, { delayMs: 1_000 }, ), delayMs: 1_000, }), ]), ], };
-
@@ -99,21 +60,10 @@export const SystemError: Story = { decorators: [ withMockedBackend([ mock( KeyValueStorageBasedWorkspaceService.method.create, () => { return create(CreateResponseSchema, { result: { case: "systemError", value: { code: "SAMPLE_ERR", message: "Test error", }, }, }); }, { delayMs: 1_000 }, ), Create({ failureRate: 1, delayMs: 1_000, }), ]), ], };
-
-
-
@@ -56,7 +56,7 @@ },}); return ( <Flex asChild direction="column" gap="5"> <Flex asChild direction="column" gap="3" mt="2"> <form className={className} onSubmit={form.handleSubmit(({ displayName }) => {
-
@@ -65,7 +65,7 @@ displayName,}); })} > {!!creation.error && ( {creation.error ? ( <ManagedErrorCallout error={creation.error} actions={
-
@@ -80,6 +80,8 @@ </Button>} title="作成に失敗しました" /> ) : ( <div /> )} <FormField.Root> <FormField.Label htmlFor="displayName">表示名</FormField.Label>
-
-
-
@@ -45,3 +45,56 @@background-size: 2rem 2rem; background-color: var(--_grid-bg); } .back { view-transition-name: back-button; } .title { view-transition-name: title; } .body { view-transition-name: body; } .body[data-root] { view-transition-name: body-root; } @keyframes pull { from { opacity: 1; translate: 0 0; } to { opacity: 0; translate: -20px 0; } } ::view-transition-old(body) { animation: 0.2s ease-out both 1 pull; } ::view-transition-new(body) { animation: 0.2s 0.1s ease-in both reverse 1 pull; } @keyframes push { from { opacity: 1; translate: 0 0; } to { opacity: 0; translate: 20px 0; } } ::view-transition-old(body-root) { animation: 0.2s ease-out both 1 push; } ::view-transition-new(body-root) { animation: 0.2s 0.1s ease-in both reverse 1 push; }
-
-
-
@@ -1,6 +1,7 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { ArrowLeftIcon } from "@radix-ui/react-icons"; import { Box, type BoxProps,
-
@@ -8,6 +9,7 @@ Card,Flex, Grid, Heading, IconButton, Inset, ScrollArea, Theme,
-
@@ -18,9 +20,17 @@ import css from "./Layout.module.css";export interface LayoutProps extends Pick<BoxProps, "className" | "children" | "style"> { title: ReactNode; onBack?(): void; } export const Layout: FC<LayoutProps> = ({ className, children, title, ...rest }) => { export const Layout: FC<LayoutProps> = ({ className, children, title, onBack, ...rest }) => { return ( <Grid className={`${css.bgGrid} ${className || ""}`}
-
@@ -34,11 +44,24 @@ ><Box asChild gridColumn="-2 / -1"> <Card> <Theme asChild panelBackground="solid"> <Flex height="100%" direction="column" gap="7" p={{ initial: "0", md: "1" }}> <Heading size="4" align="center"> {title} </Heading> <Inset> <Flex height="100%" direction="column" gap="5" p={{ initial: "0", md: "1" }}> <Flex align="center"> {onBack && ( <IconButton className={css.back} variant="ghost" onClick={() => void onBack()} > <ArrowLeftIcon /> </IconButton> )} <Box className={css.title} asChild flexGrow="1" flexShrink="1"> <Heading size="4" align="center"> {title} </Heading> </Box> </Flex> <Inset className={css.body} data-root={onBack ? "" : undefined}> <ScrollArea> <Box px={{ initial: "2", xs: "3" }} pb="3" pt="0"> {children}
-
-
-
@@ -0,0 +1,129 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { UpdateIcon, PlusIcon } from "@radix-ui/react-icons"; import { Button, Flex, Spinner, Text } from "@radix-ui/themes"; import { type UseQueryResult } from "@tanstack/react-query"; import { ListRequestSchema } from "@yamori/proto/yamori/workspace/v1/list_request_pb.js"; import { ListResponseSchema, type ListResponse_Result, } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js"; import { type FC, useEffect } from "react"; import { useMethodQuery } from "../../contexts/Service.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import { ManagedErrorCallout } from "../ErrorCallout.ts"; import { WorkspaceList } from "./WorkspaceList.tsx"; export interface SelectorProps { onOpenCreationForm?(): void; } export const Selector: FC<SelectorProps> = ({ onOpenCreationForm }) => { const list = useMethodQuery({ service: "yamori.workspace.v1.KeyValueStorageBasedWorkspaceService", method: "List", request: { schema: ListRequestSchema, data: {}, }, response: { schema: ListResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value; case "systemError": throw resp.result.value; default: throw new IllegalMessageError(resp); } }, }); useEffect(() => { if (list.isFetching || list.data?.workspaces.length !== 0) { return; } onOpenCreationForm?.(); }, [list.isFetching, list.data?.workspaces.length]); return ( <Flex direction="column"> <Flex justify="end" align="center" gap="2" p="2"> <Button variant="outline" size="1" loading={list.isFetching} onClick={() => void list.refetch()} > <UpdateIcon /> 更新 </Button> <Button variant="outline" size="1" onClick={() => void onOpenCreationForm?.()}> <PlusIcon /> 作成 </Button> </Flex> <Contents list={list} /> </Flex> ); }; interface ContentsProps { list: UseQueryResult<ListResponse_Result, unknown>; } const Contents: FC<ContentsProps> = ({ list }) => { if (list.status === "pending") { return ( <Flex align="center" justify="center" pt="7" gap="2"> <Spinner /> <Text size="2" color="gray"> ワークスペースを読込中... </Text> </Flex> ); } if (!list.data) { return ( <ManagedErrorCallout title="一覧の取得に失敗しました" error={list.error} actions={ <Button size="1" loading={list.isFetching} onClick={() => void list.refetch()}> 再取得 </Button> } /> ); } return ( <Flex direction="column" gap="5"> {!!list.error && ( <ManagedErrorCallout severity="warning" title="最新の一覧の取得に失敗しました" error={list.error} actions={ <Button size="1" loading={list.isFetching} onClick={() => void list.refetch()} > 再試行 </Button> } /> )} <WorkspaceList workspaces={list.data.workspaces} /> </Flex> ); };
-
-
-
@@ -0,0 +1,23 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { flushSync } from "react-dom"; type ViewTransitionCallback = () => Promise<void> | void; function runTransition(callback: ViewTransitionCallback) { if (!document.startViewTransition) { callback(); return; } document.startViewTransition(() => { flushSync(() => { callback(); }); }); } export function useViewTransition(): (callback: ViewTransitionCallback) => void { return runTransition; }
-
-
-
@@ -0,0 +1,109 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type MessageInitShape } from "@bufbuild/protobuf"; import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { KeyValueStorageBasedWorkspaceService } from "@yamori/proto/yamori/workspace/v1/key_value_storage_based_workspace_service_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 { workspaces?: MessageInitShape<typeof WorkspaceSchema>[]; failureRate?: number; } export function List({ workspaces = [ { id: { value: "ws-foo" }, displayName: "株式会社あああ", }, { id: { value: "ws-bar" }, displayName: "有限会社いいい", }, { id: { value: "ws-baz" }, displayName: "NPO法人 ううう", }, ], failureRate = 0, ...options }: ListOptions = {}) { return mock( KeyValueStorageBasedWorkspaceService.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: { workspaces }, }, }; }, options, ); } export interface CreateOptions extends MockOptions { failureRate?: number; error?: Exclude< MessageInitShape<typeof CreateResponseSchema>["result"], { case: "ok" | undefined } >; } export function Create({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is sample error message", }, }, ...options }: CreateOptions = {}) { return mock( KeyValueStorageBasedWorkspaceService.method.create, (req) => { if (Math.random() < failureRate) { return { result: error, }; } const id = "ws-" + crypto.randomUUID(); return { result: { case: "ok" as const, value: { workspace: { id: { value: id }, displayName: req.displayName, }, }, }, }; }, options, ); }
-
-
-
@@ -10,5 +10,6 @@ "declaration": true,"emitDeclarationOnly": true, "outDir": "./types" }, "include": ["src/**/*.ts", "src/**/*.tsx"] "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": ["src/mocks"] }
-
-
-
@@ -16,7 +16,7 @@ fileName: "lib",cssFileName: "styles", }, rollupOptions: { external: ["react", /^react\//, "@radix-ui/themes"], external: ["react", "react-dom", /^react\//, "@radix-ui/themes"], }, }, plugins: [react()],
-