Changes
13 changed files (+505/-2)
-
-
-
@@ -27,8 +27,7 @@ "@radix-ui/themes": "^3.1.6","@tanstack/react-query": "^5.62.8", "@yamori/idb_backend": "workspace:*", "@yamori/proto": "workspace:*", "i": "^0.3.7", "npm": "^11.0.0" "react-hook-form": "^7.54.2" }, "devDependencies": { "@storybook/addon-essentials": "^8.4.7",
-
-
-
@@ -10,6 +10,8 @@ import * as IllegalMessageError from "./errors/IllegalMessageError.tsx";import * as GenericError from "./errors/GenericError.tsx"; import * as UnhandledMessageError from "./errors/UnhandledMessageError.tsx"; import * as NonErrorThrownError from "./errors/NonErrorThrownError.tsx"; import * as NoStorageSpaceError from "./errors/NoStorageSpaceError.tsx"; import * as MissingFieldError from "./errors/MissingFieldError.tsx"; export interface ManagedProps extends ErrorCalloutProps { error: unknown;
-
@@ -18,6 +20,14 @@export const Managed: FC<ManagedProps> = ({ error, ...rest }) => { if (SystemError.is(error)) { return <SystemError.Callout error={error} {...rest} />; } if (NoStorageSpaceError.is(error)) { return <NoStorageSpaceError.Callout error={error} {...rest} />; } if (MissingFieldError.is(error)) { return <MissingFieldError.Callout error={error} {...rest} />; } if (UnhandledMessageError.is(error)) {
-
-
-
@@ -0,0 +1,28 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { MissingFieldErrorSchema } from "@yamori/proto/yamori/error/v1/missing_field_error_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { Callout } from "./MissingFieldError.tsx"; export default { component: Callout, args: { title: "エラー", error: create(MissingFieldErrorSchema, { path: "foo.bar", }), }, } satisfies Meta<typeof Callout>; type Story = StoryObj<typeof Callout>; export const Defaults: Story = {}; export const NoPath: Story = { args: { error: create(MissingFieldErrorSchema, {}), }, };
-
-
-
@@ -0,0 +1,76 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { isMessage } from "@bufbuild/protobuf"; import { Button, Code, DataList, Dialog, Flex, Text } from "@radix-ui/themes"; import { MissingFieldErrorSchema, type MissingFieldError, } from "@yamori/proto/yamori/error/v1/missing_field_error_pb.js"; import { type FC } from "react"; import { ErrorCallout, type ErrorCalloutProps } from "../ErrorCallout.tsx"; export function is(x: unknown): x is MissingFieldError { return isMessage(x, MissingFieldErrorSchema); } export interface CalloutProps extends ErrorCalloutProps { error: MissingFieldError; } export const Callout: FC<CalloutProps> = ({ error, actions, children, ...rest }) => { return ( <Dialog.Root> <ErrorCallout {...rest} actions={ <> {actions} <Dialog.Trigger> <Button size="1" variant="outline"> 詳細 </Button> </Dialog.Trigger> </> } > {children || (error.path ? `${error.path} が未指定です` : "必須フィールドが未指定です")} </ErrorCallout> <Dialog.Content> <Dialog.Title>必須フィールド未指定エラー</Dialog.Title> <Dialog.Description size="2"> 処理に必須な項目が未指定、もしくは空値の場合のエラーです。 </Dialog.Description> <Text as="p" size="2" mt="2"> アプリケーション上ではこのエラーが起こらないようにチェック処理を行っています。 そのため、このエラーが表示される場合は特定のデータにおけるアプリケーションの不具合の可能性が高いです。 </Text> <Text as="p" size="2" mt="2"> 入力項目がある場合は値を変えて再試行すると解消される場合があります。 </Text> {error.path && ( <DataList.Root mt="5" orientation={{ initial: "vertical", sm: "horizontal" }}> <DataList.Item> <DataList.Label>フィールド</DataList.Label> <DataList.Value> <Code>{error.path}</Code> </DataList.Value> </DataList.Item> </DataList.Root> )} <Flex justify="end" mt="5"> <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> </Flex> </Dialog.Content> </Dialog.Root> ); };
-
-
-
@@ -0,0 +1,28 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { NoStorageSpaceSchema } from "@yamori/proto/yamori/error/v1/no_storage_space_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { Callout } from "./NoStorageSpaceError.tsx"; export default { component: Callout, args: { title: "エラー", error: create(NoStorageSpaceSchema, { message: "Storage is full.", }), }, } satisfies Meta<typeof Callout>; type Story = StoryObj<typeof Callout>; export const Defaults: Story = {}; export const NoMessage: Story = { args: { error: create(NoStorageSpaceSchema, {}), }, };
-
-
-
@@ -0,0 +1,68 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { isMessage } from "@bufbuild/protobuf"; import { Button, DataList, Dialog, Flex, Text } from "@radix-ui/themes"; import { NoStorageSpaceSchema, type NoStorageSpace, } from "@yamori/proto/yamori/error/v1/no_storage_space_pb.js"; import { type FC } from "react"; import { ErrorCallout, type ErrorCalloutProps } from "../ErrorCallout.tsx"; export function is(x: unknown): x is NoStorageSpace { return isMessage(x, NoStorageSpaceSchema); } export interface CalloutProps extends ErrorCalloutProps { error: NoStorageSpace; } export const Callout: FC<CalloutProps> = ({ error, actions, children, ...rest }) => { return ( <Dialog.Root> <ErrorCallout {...rest} actions={ <> {actions} <Dialog.Trigger> <Button size="1" variant="outline"> 詳細 </Button> </Dialog.Trigger> </> } > {children || "保存領域がありません"} </ErrorCallout> <Dialog.Content> <Dialog.Title>保存領域エラー</Dialog.Title> <Dialog.Description size="2"> データの追加・更新を行おうとした際に保存領域が足りず、処理が中断された際のエラーです。 </Dialog.Description> <Text as="p" size="2" mt="2"> 既存のデータを削除することで解消される場合があります。 </Text> {error.message && ( <DataList.Root mt="5" orientation={{ initial: "vertical", sm: "horizontal" }}> <DataList.Item> <DataList.Label>メッセージ</DataList.Label> <DataList.Value>{error.message}</DataList.Value> </DataList.Item> </DataList.Root> )} <Flex justify="end" mt="5"> <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> </Flex> </Dialog.Content> </Dialog.Root> ); };
-
-
-
@@ -0,0 +1,6 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only export * from "./FormField/Root.tsx"; export * from "./FormField/Label.tsx"; export * from "./FormField/Description.tsx";
-
-
-
@@ -0,0 +1,27 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Text, type TextProps } from "@radix-ui/themes"; import { type FC, type ReactNode } from "react"; export interface DescriptionProps extends Omit<TextProps, "as" | "size"> { /** * エラーとして表示するメッセージ。 * このプロパティが表示される場合はこちらを優先して表示し、見た目も * エラーとわかりやすい状態になる。 */ error?: ReactNode; } export const Description: FC<DescriptionProps> = ({ error, children, color = "gray", ...rest }) => { return ( <Text {...rest} as="span" size="1" color={error ? "red" : color}> {error || children} </Text> ); };
-
-
-
@@ -0,0 +1,18 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Text, type TextProps } from "@radix-ui/themes"; import { type FC } from "react"; export type LabelProps = Omit< Extract<TextProps, { as: "label" }>, "as" | "size" | "weight" | "asChild" >; export const Label: FC<LabelProps> = ({ children, ...rest }) => { return ( <Text {...rest} as="label" size="1" weight="bold"> {children} </Text> ); };
-
-
-
@@ -0,0 +1,15 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Flex, type FlexProps } from "@radix-ui/themes"; import { type FC } from "react"; export type RootProps = Omit<FlexProps, "direction" | "gap">; export const Root: FC<RootProps> = ({ children, ...rest }) => { return ( <Flex {...rest} direction="column" gap="1"> {children} </Flex> ); };
-
-
-
@@ -0,0 +1,121 @@// 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, mock, } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { CreationForm } from "./CreationForm.tsx"; export default { component: CreationForm, } 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), ]), ], }; export const SlowLoad: Story = { decorators: [ withMockedBackend([ mock(KeyValueStorageBasedWorkspaceService.method.create, mockHandler, { delayMs: 2_000, }), ]), ], }; export const MissingField: Story = { decorators: [ withMockedBackend([ mock( KeyValueStorageBasedWorkspaceService.method.create, () => { return create(CreateResponseSchema, { result: { case: "missingField", value: { path: "display_name", }, }, }); }, { delayMs: 1_000 }, ), ]), ], }; export const NoStorageSpace: Story = { decorators: [ withMockedBackend([ mock( KeyValueStorageBasedWorkspaceService.method.create, () => { return create(CreateResponseSchema, { result: { case: "noStorageSpace", value: { message: "Test error", }, }, }); }, { delayMs: 1_000 }, ), ]), ], }; 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 }, ), ]), ], }; export const Playground: Story = {};
-
-
-
@@ -0,0 +1,107 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Button, Flex, TextField } from "@radix-ui/themes"; import { CreateRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { type FC } from "react"; import { useForm } from "react-hook-form"; import { ManagedErrorCallout } from "../ErrorCallout.ts"; import * as FormField from "../FormField.ts"; import { useMethodMutation } from "../../contexts/Service.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; export interface CreationFormProps { className?: string | undefined; onCreated?(): void; } export const CreationForm: FC<CreationFormProps> = ({ className, onCreated }) => { const form = useForm<{ displayName: string; }>({ defaultValues: { displayName: "", }, mode: "onBlur", }); const creation = useMethodMutation({ service: "yamori.workspace.v1.KeyValueStorageBasedWorkspaceService", method: "Create", request: { schema: CreateRequestSchema, }, response: { schema: CreateResponseSchema, }, mapResponse(resp) { if (resp.result.case === "ok") { return resp.result.value; } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, options: { onSuccess() { onCreated?.(); }, }, }); return ( <Flex asChild direction="column" gap="5"> <form className={className} onSubmit={form.handleSubmit(({ displayName }) => { creation.mutate({ displayName, }); })} > {!!creation.error && ( <ManagedErrorCallout error={creation.error} actions={ <Button size="1" variant="outline" type="button" onClick={() => void creation.reset()} > 閉じる </Button> } title="作成に失敗しました" /> )} <FormField.Root> <FormField.Label htmlFor="displayName">表示名</FormField.Label> <TextField.Root disabled={creation.isPending} placeholder="株式会社◯◯◯" color={form.formState.errors.displayName ? "red" : undefined} aria-invalid={!!form.formState.errors.displayName} autoComplete="off" {...form.register("displayName", { required: "表示名は必須です", setValueAs(value: string) { return value.trim(); }, })} /> <FormField.Description error={form.formState.errors.displayName?.message}> ワークスペースの名前です。前後の空白は自動的に取り除かれます。 </FormField.Description> </FormField.Root> <Button loading={creation.isPending}>作成</Button> </form> </Flex> ); };
-