Changes
5 changed files (+303/-13)
-
-
@@ -335,3 +335,50 @@ },options, ); } export interface DeleteCustomAttributeDefinitionOptions extends MockOptions { failureRate?: number; error?: Exclude< MessageInitShape< typeof WorkspaceService.method.deleteCustomAttributeDefinition.output >["result"], { case: "ok" | undefined } >; } export function DeleteCustomAttributeDefinition({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is a sample error", }, }, ...options }: DeleteCustomAttributeDefinitionOptions = {}) { return mock( WorkspaceService.method.deleteCustomAttributeDefinition, (req) => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: { id: req.id ?? { value: "cf-" + crypto.randomUUID(), }, displayName: "Foo", }, }, }; }, options, ); }
-
-
-
@@ -0,0 +1,80 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Button } from "@radix-ui/themes"; import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { expect, userEvent, waitFor, within } from "@storybook/test"; import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { DeleteCustomAttributeDefinition } from "../../mocks/yamori/workspace/v2/workspace_service.ts"; import * as DeleteDialog from "./DeleteDialog.tsx"; export default { component: DeleteDialog.Content, args: { id: { value: "cf-foo" }, onDeleteError: action("onDeleteError"), onDeleteStart: action("onDeleteStart"), onDeleted: action("onDeleted"), }, render(args) { return ( <DeleteDialog.Root> <DeleteDialog.Trigger> <Button data-testid="trigger">開く</Button> </DeleteDialog.Trigger> <DeleteDialog.Content {...args} /> </DeleteDialog.Root> ); }, } satisfies Meta<typeof DeleteDialog.Content>; type Story = StoryObj<typeof DeleteDialog.Content>; export const Demo: Story = { decorators: [withMockedBackend([DeleteCustomAttributeDefinition()])], async play({ canvas }) { await userEvent.click(canvas.getByTestId("trigger")); }, }; export const Success: Story = { decorators: [withMockedBackend([DeleteCustomAttributeDefinition()])], async play({ canvas }) { await userEvent.click(canvas.getByTestId("trigger")); const root = within(document.documentElement); await userEvent.click(root.getByRole("button", { name: /削除する/ })); await waitFor(() => expect(root.getByText(/削除しました/)).toBeInTheDocument()); }, }; export const SlowLoad: Story = { decorators: [withMockedBackend([DeleteCustomAttributeDefinition({ delayMs: 2_000 })])], async play({ canvas }) { await userEvent.click(canvas.getByTestId("trigger")); const root = within(document.documentElement); await userEvent.click(root.getByRole("button", { name: /削除する/ })); }, }; export const SystemError: Story = { decorators: [ withMockedBackend([ DeleteCustomAttributeDefinition({ failureRate: 1, }), ]), ], async play({ canvas }) { await userEvent.click(canvas.getByTestId("trigger")); const root = within(document.documentElement); await userEvent.click(root.getByRole("button", { name: /削除する/ })); await waitFor(() => expect(root.getByText(/失敗しました/)).toBeInTheDocument()); }, };
-
-
-
@@ -0,0 +1,110 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type MessageInitShape } from "@bufbuild/protobuf"; import { createClient } from "@connectrpc/connect"; import { AlertDialog, Button, Flex } from "@radix-ui/themes"; import { useMutation } from "@tanstack/react-query"; import { type CustomAttributeDefinitionIDSchema } from "@yamori/proto/yamori/workspace/v2/custom_attribute_definition_id_pb.js"; import { type DeleteCustomAttributeDefinitionRequestSchema } from "@yamori/proto/yamori/workspace/v2/delete_custom_attribute_definition_request_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type FC } from "react"; import { useConnectTransport } from "../../contexts/ConnectTransport.tsx"; import { useToast } from "../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; export const Root = AlertDialog.Root; export const Trigger = AlertDialog.Trigger; export interface ContentProps { id: MessageInitShape<typeof CustomAttributeDefinitionIDSchema>; onDeleteStart?(): void; onDeleted?(): void; onDeleteError?(): void; } export const Content: FC<ContentProps> = ({ id, onDeleted, onDeleteError, onDeleteStart, }) => { const toast = useToast(); const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const deletion = useMutation({ async mutationFn( req: MessageInitShape<typeof DeleteCustomAttributeDefinitionRequestSchema>, ) { const resp = await client.deleteCustomAttributeDefinition(req); if (resp.result.case === "ok") { return resp.result.value; } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, onSuccess(def) { toast.open({ severity: "success", title: `カスタムフィールド定義「${def.displayName}」を削除しました`, dismissible: true, type: "background", }); onDeleted?.(); }, onError(error: unknown) { // TODO: エラーをユーザに表示する (共通のエラー文字列化処理) console.error(error); toast.open({ severity: "danger", title: `カスタムフィールド定義の削除に失敗しました`, dismissible: true, type: "foreground", }); onDeleteError?.(); }, }); return ( <AlertDialog.Content maxWidth="30em"> <AlertDialog.Title>削除確認</AlertDialog.Title> <AlertDialog.Description size="2"> カスタムフィールドの定義を削除します。 ユーザに入力されているこのカスタムフィールドの値も削除されます。 <br /> この操作は取り消せません。削除しますか? </AlertDialog.Description> <Flex gap="3" mt="4" justify="end"> <AlertDialog.Cancel> <Button variant="soft" color="gray"> キャンセル </Button> </AlertDialog.Cancel> <AlertDialog.Action> <Button variant="solid" color="red" onClick={() => { deletion.mutate({ id }); onDeleteStart?.(); }} > 削除する </Button> </AlertDialog.Action> </Flex> </AlertDialog.Content> ); };
-
-
-
@@ -1,13 +1,16 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { UserSchema } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../.storybook/decorators/withInmemoryRouter.tsx"; import { alice, bob, Get } from "../../mocks/yamori/workspace/v2/workspace_service.ts"; import { alice, bob, DeleteCustomAttributeDefinition, Get, } from "../../mocks/yamori/workspace/v2/workspace_service.ts"; import { createHref, Page } from "./page.tsx";
-
@@ -25,7 +28,7 @@type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([Get()])], decorators: [withMockedBackend([Get(), DeleteCustomAttributeDefinition()])], }; export const Empty: Story = {
-
@@ -52,5 +55,10 @@ decorators: [withMockedBackend([Get({ failureRate: 1 })])],}; export const SlowLoad: Story = { decorators: [withMockedBackend([Get({ delayMs: 2_000 })])], decorators: [ withMockedBackend([ Get({ delayMs: 2_000 }), DeleteCustomAttributeDefinition({ delayMs: 2_000 }), ]), ], };
-
-
-
@@ -4,11 +4,19 @@import "../../polyfill.ts"; import { createClient } from "@connectrpc/connect"; import { Button, Container, Flex, Separator, Spinner, Text } from "@radix-ui/themes"; import { Button, Container, DropdownMenu, Flex, Separator, Spinner, Text, } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; import { type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type FC, Fragment } from "react"; import { type FC, Fragment, useState } from "react"; import * as Empty from "../../components/Empty.ts"; import * as HelpDialog from "../../components/HelpDialog.ts";
-
@@ -17,12 +25,19 @@ import { useConnectTransport } from "../../contexts/ConnectTransport.tsx";import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import { LoggedInLayout } from "../LoggedInLayout.tsx"; import * as DeleteDialog from "./DeleteDialog.tsx"; export const Title: FC = () => "カスタムフィールド定義一覧"; export const Body: FC = () => { interface BodyProps { canMutate: boolean; } const Body: FC<BodyProps> = ({ canMutate }) => { const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const [isDeleting, setIsDeleting] = useState(false); const defs = useQuery({ queryKey: [
-
@@ -108,12 +123,42 @@ />)} <Flex direction="column" gap="4" role="list"> {defs.data.map((def, i) => { if (!def.id) { return null; } return ( <Fragment key={def.id?.value}> <Fragment key={def.id.value}> {i > 0 && <Separator size="4" />} <Flex direction="column" gap="2" role="listitem"> <Text weight="bold">{def.displayName}</Text> </Flex> <DeleteDialog.Root> <DropdownMenu.Root> <Flex gap="2" align="center" justify="between" role="listitem"> <Text weight="bold">{def.displayName}</Text> {canMutate && ( <DropdownMenu.Trigger disabled={isDeleting}> <Button variant="soft"> 操作 <DropdownMenu.TriggerIcon /> </Button> </DropdownMenu.Trigger> )} </Flex> <DropdownMenu.Content> <DeleteDialog.Trigger> <DropdownMenu.Item color="red">削除</DropdownMenu.Item> </DeleteDialog.Trigger> </DropdownMenu.Content> <DeleteDialog.Content id={def.id} onDeleteStart={() => void setIsDeleting(true)} onDeleted={() => { setIsDeleting(false); defs.refetch(); }} onDeleteError={() => void setIsDeleting(false)} /> </DropdownMenu.Root> </DeleteDialog.Root> </Fragment> ); })}
-
@@ -139,7 +184,7 @@ }user={loginUser} > <Container p="2" size="2"> <Body /> <Body canMutate={!!loginUser.permissions?.canUpdateWorkspace} /> </Container> </LoggedInLayout> <HelpDialog.Content>
-