Changes
10 changed files (+541/-0)
-
-
@@ -5,6 +5,7 @@ import { type FC } from "react";import { type ErrorCalloutProps } from "./ErrorCallout.tsx"; import * as CapabilityError from "./errors/CapabilityError.tsx"; import * as SystemError from "./errors/SystemError.tsx"; import * as IllegalMessageError from "./errors/IllegalMessageError.tsx"; import * as GenericError from "./errors/GenericError.tsx";
-
@@ -12,12 +13,17 @@ 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"; import * as WorkspaceAccessError from "./errors/WorkspaceAccessError.tsx"; export interface ManagedProps extends ErrorCalloutProps { error: unknown; } export const Managed: FC<ManagedProps> = ({ error, ...rest }) => { if (CapabilityError.is(error)) { return <CapabilityError.Callout error={error} {...rest} />; } if (SystemError.is(error)) { return <SystemError.Callout error={error} {...rest} />; }
-
@@ -28,6 +34,10 @@ }if (MissingFieldError.is(error)) { return <MissingFieldError.Callout error={error} {...rest} />; } if (WorkspaceAccessError.is(error)) { return <WorkspaceAccessError.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 { CapabilityErrorSchema } from "@yamori/proto/yamori/error/v1/capability_error_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { Callout } from "./CapabilityError.tsx"; export default { component: Callout, args: { title: "エラー", error: create(CapabilityErrorSchema, { path: "foo_bar_key", }), }, } satisfies Meta<typeof Callout>; type Story = StoryObj<typeof Callout>; export const Defaults: Story = {}; export const EmptyPath: Story = { args: { error: create(CapabilityErrorSchema, {}), }, };
-
-
-
@@ -0,0 +1,73 @@// 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 { CapabilityErrorSchema, type CapabilityError, } from "@yamori/proto/yamori/error/v1/capability_error_pb.js"; import { type FC } from "react"; import { ErrorCallout, type ErrorCalloutProps } from "../ErrorCallout.tsx"; export function is(x: unknown): x is CapabilityError { return isMessage(x, CapabilityErrorSchema); } export interface CalloutProps extends ErrorCalloutProps { error: CapabilityError; } 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> <DataList.Root mt="5" orientation={{ initial: "vertical", sm: "horizontal" }}> <DataList.Item> <DataList.Label>権限キー名</DataList.Label> <DataList.Value> {error.path ? <Code>{error.path}</Code> : "---"} </DataList.Value> </DataList.Item> </DataList.Root> <Text as="p" color="gray" size="1" mt="5"> 上記は問い合わせの際に必要な情報となります。 </Text> <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 { AccessErrorSchema } from "@yamori/proto/yamori/workspace/v1/access_error_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { Callout } from "./WorkspaceAccessError.tsx"; export default { component: Callout, args: { title: "エラー", error: create(AccessErrorSchema, { workspaceId: { value: "ws-foo" }, }), }, } satisfies Meta<typeof Callout>; type Story = StoryObj<typeof Callout>; export const Defaults: Story = {}; export const NoWorkspaceId: Story = { args: { error: create(AccessErrorSchema, {}), }, };
-
-
-
@@ -0,0 +1,73 @@// 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 { AccessErrorSchema, type AccessError, } from "@yamori/proto/yamori/workspace/v1/access_error_pb.js"; import { type FC } from "react"; import { ErrorCallout, type ErrorCalloutProps } from "../ErrorCallout.tsx"; export function is(x: unknown): x is AccessError { return isMessage(x, AccessErrorSchema); } export interface CalloutProps extends ErrorCalloutProps { error: AccessError; } 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> <DataList.Root mt="5" orientation={{ initial: "vertical", sm: "horizontal" }}> <DataList.Item> <DataList.Label>ワークスペースID</DataList.Label> <DataList.Value> {error.workspaceId?.value ? <Code>{error.workspaceId.value}</Code> : "---"} </DataList.Value> </DataList.Item> </DataList.Root> <Text as="p" color="gray" size="1" mt="5"> 上記は問い合わせの際に必要な情報となります。 </Text> <Flex justify="end" mt="5"> <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> </Flex> </Dialog.Content> </Dialog.Root> ); };
-
-
-
@@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com>// SPDX-License-Identifier: AGPL-3.0-only import { type MessageInitShape } from "@bufbuild/protobuf"; import { type CreateResponseSchema } from "@yamori/proto/yamori/worker/v1/create_response_pb.js"; 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";
-
@@ -72,3 +73,51 @@ },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 from mock handler for yamori.worker.v1.WorkerService.Create", }, }, ...options }: CreateOptions = {}) { return mock( WorkerService.method.create, (req) => { if (Math.random() < failureRate) { return { result: error, }; } const id = "wr-" + crypto.randomUUID(); return { result: { case: "ok" as const, value: { worker: { id: { value: id }, displayName: req.displayName, }, }, }, }; }, options, ); }
-
-
-
@@ -19,6 +19,7 @@ import { URLContext } from "../../contexts/Router.tsx";import { useViewTransition } from "../../hooks/useViewTransition.ts"; import * as workers from "./workers/page.tsx"; import * as workersNew from "./workers/new/page.tsx"; import css from "./Layout.module.css";
-
@@ -97,6 +98,11 @@ <NavigationMenu.Group title="労働者管理"><NavigationMenu.Item current={workers.pattern.test(url)}> <a href={`/${workspace.id?.value}/workers`}>労働者一覧</a> </NavigationMenu.Item> {workspace.workerAddKey && ( <NavigationMenu.Item current={workersNew.pattern.test(url)}> <a href={`/${workspace.id?.value}/workers/new`}>労働者登録</a> </NavigationMenu.Item> )} </NavigationMenu.Group> </NavigationMenu.Root> </Box>
-
-
-
@@ -12,6 +12,7 @@ import { useMethodQuery } from "../../contexts/Service.tsx";import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import * as workers from "./workers/page.tsx"; import * as workersNew from "./workers/new/page.tsx"; import { Layout } from "./Layout.tsx"; export const Page: FC = () => {
-
@@ -57,6 +58,10 @@ routes={[{ pattern: workers.pattern, children: <workers.Page workspace={workspace} />, }, { pattern: workersNew.pattern, children: <workersNew.Page workspace={workspace} />, }, ]} fallback={
-
-
-
@@ -0,0 +1,102 @@// 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 type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { Create } from "../../../../mocks/yamori/worker/v1/worker_service.ts"; import { Page } from "./page.tsx"; const workspace = create(WorkspaceSchema, { id: { value: "ws-foo" }, workerAddKey: { key: new Uint8Array([]) }, }); export default { component: Page, parameters: { layout: "fullscreen", }, args: { workspace, }, decorators: [withInmemoryRouter({ initialURL: "/ws-foo/workers/new" })], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([Create()])], }; export const SlowLoad: Story = { decorators: [withMockedBackend([Create({ delayMs: 2_000 })])], }; export const NoCapability: Story = { args: { workspace: create(WorkspaceSchema, { id: { value: "ws-foo" }, }), }, }; export const SystemError: Story = { decorators: [withMockedBackend([Create({ delayMs: 1_000, failureRate: 1 })])], }; export const Flaky: Story = { decorators: [withMockedBackend([Create({ delayMs: 1_000, failureRate: 0.5 })])], }; export const CapabilityError: Story = { decorators: [ withMockedBackend([ Create({ failureRate: 1, error: { case: "capabilityError", value: { path: "worker_add_key", }, }, }), ]), ], }; export const WorkspaceAccessError: Story = { decorators: [ withMockedBackend([ Create({ failureRate: 1, error: { case: "workspaceAccessError", value: { workspaceId: { value: "ws-foo" }, }, }, }), ]), ], }; export const MissingField: Story = { decorators: [ withMockedBackend([ Create({ failureRate: 1, error: { case: "missingField", value: { path: "display_name", }, }, }), ]), ], };
-
-
-
@@ -0,0 +1,167 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../../polyfill.ts"; import { CheckCircledIcon } from "@radix-ui/react-icons"; import { Button, Flex, Heading, Text, TextField } from "@radix-ui/themes"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { CreateRequestSchema } from "@yamori/proto/yamori/worker/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/worker/v1/create_response_pb.js"; import { type FC, use } from "react"; import { useForm } from "react-hook-form"; import { ManagedErrorCallout } from "../../../../components/ErrorCallout.ts"; import * as FormField from "../../../../components/FormField.ts"; import { useMethodMutation } from "../../../../contexts/Service.tsx"; import { NavigationContext } from "../../../../contexts/Router.tsx"; import { useToast } from "../../../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../../../errors/IllegalMessageError.ts"; import { Layout } from "../../Layout.tsx"; interface BodyProps { workspace: Workspace; } const Body: FC<BodyProps> = ({ workspace }) => { const navigation = use(NavigationContext); const toast = useToast(); const form = useForm<{ displayName: string; }>({ defaultValues: { displayName: "", }, mode: "onBlur", }); const creation = useMethodMutation({ service: "yamori.worker.v1.WorkerService", method: "Create", request: { schema: CreateRequestSchema, }, response: { schema: CreateResponseSchema, }, mapResponse(resp) { if (resp.result.case === "ok") { if (!resp.result.value.worker) { throw new IllegalMessageError(resp); } return resp.result.value.worker; } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, options: { onSuccess(worker) { toast.open({ icon: <CheckCircledIcon />, severity: "success", title: "労働者を登録しました", description: ( <>「{worker.displayName}」を労働者としてワークスペースに登録しました。</> ), type: "foreground", }); navigation.push(`/${workspace.id?.value}/workers`); }, }, }); if (!workspace.workerAddKey) { 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"> <a href={`/${workspace.id?.value}/workers`}>一覧へ</a> </Button> </Flex> ); } return ( <Flex asChild direction="column" gap="3" mt="2"> <form onSubmit={form.handleSubmit(({ displayName }) => { creation.mutate({ displayName, workspaceId: workspace.id, workerAddKey: workspace.workerAddKey, }); })} > {creation.error ? ( <ManagedErrorCallout error={creation.error} title="登録に失敗しました" actions={ <Button size="1" variant="outline" type="button" onClick={() => void creation.reset()} > 閉じる </Button> } /> ) : ( <div /> )} <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 mt="5" loading={creation.isPending}> 作成 </Button> </form> </Flex> ); }; export interface PageProps { workspace: Workspace; } export const Page: FC<PageProps> = ({ workspace }) => { return ( <Layout workspace={workspace} title="労働者登録"> <Body workspace={workspace} /> </Layout> ); }; export const pattern = new URLPattern({ pathname: "/:workspace/workers/new", });
-