Changes
5 changed files (+320/-13)
-
-
@@ -140,6 +140,55 @@ options,); } export interface DeleteUserOptions extends MockOptions { failureRate?: number; ok?: Extract< MessageInitShape<typeof WorkspaceService.method.deleteUser.output>["result"], { case: "ok" } >["value"]; error?: Exclude< MessageInitShape<typeof WorkspaceService.method.deleteUser.output>["result"], { case: "ok" | undefined } >; } export function DeleteUser({ failureRate = 0, ok = { id: { value: "wu-foo" }, displayName: "Foo", }, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is a sample error", }, }, ...options }: DeleteUserOptions = {}) { return mock( WorkspaceService.method.deleteUser, (_req) => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: ok, }, }; }, options, ); } export interface LoginOptions extends MockOptions { failureRate?: number;
-
-
-
@@ -0,0 +1,78 @@// 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 { DeleteUser } from "../../mocks/yamori/workspace/v2/workspace_service.ts"; import * as DeleteDialog from "./DeleteDialog.tsx"; export default { component: DeleteDialog.Content, args: { user: { id: { value: "wu-", }, }, 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([DeleteUser()])], async play({ canvas }) { await userEvent.click(canvas.getByTestId("trigger")); }, }; export const Success: Story = { decorators: [withMockedBackend([DeleteUser()])], 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([DeleteUser({ 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([DeleteUser({ 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,108 @@// 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 UserSchema } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { type DeleteUserRequestSchema } from "@yamori/proto/yamori/workspace/v2/delete_user_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 { user: MessageInitShape<typeof UserSchema>; onDeleteStart?(): void; onDeleted?(): void; onDeleteError?(): void; } export const Content: FC<ContentProps> = ({ user, onDeleted, onDeleteError, onDeleteStart, }) => { const toast = useToast(); const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const deletion = useMutation({ async mutationFn(req: MessageInitShape<typeof DeleteUserRequestSchema>) { const resp = await client.deleteUser(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"> ユーザ「{user.displayName ?? user.name}」を削除します。 ユーザの記録も同時に消去されます。 <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: user.id }); onDeleteStart?.(); }} > 削除する </Button> </AlertDialog.Action> </Flex> </AlertDialog.Content> ); };
-
-
-
@@ -7,7 +7,7 @@ import type { Meta, StoryObj } from "@storybook/react";import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../.storybook/decorators/withInmemoryRouter.tsx"; import { Get } from "../../mocks/yamori/workspace/v2/workspace_service.ts"; import { DeleteUser, Get } from "../../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page } from "./page.tsx";
-
@@ -22,7 +22,9 @@ id: { value: "wu-alice" },displayName: "Alice", permissions: { canReadOtherUserProfile: true, canDeleteRegularUser: true, }, isAdmin: false, }), }, decorators: [withInmemoryRouter({ initialURL: "/users" })],
-
@@ -31,7 +33,7 @@type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([Get()])], decorators: [withMockedBackend([Get(), DeleteUser()])], }; export const NoPermission: Story = {
-
@@ -42,13 +44,29 @@ displayName: "Alice",permissions: {}, }), }, decorators: [withMockedBackend([Get()])], decorators: [withMockedBackend([Get(), DeleteUser()])], }; export const NoDeletePermission: Story = { args: { loginUser: create(UserSchema, { id: { value: "wu-alice" }, displayName: "Alice", permissions: { canReadOtherUserProfile: true, canDeleteRegularUser: false, }, }), }, decorators: [withMockedBackend([Get(), DeleteUser({ failureRate: 1 })])], }; export const SystemError: Story = { decorators: [withMockedBackend([Get({ failureRate: 1 })])], decorators: [withMockedBackend([Get({ failureRate: 1 }), DeleteUser()])], }; export const SlowLoad: Story = { decorators: [withMockedBackend([Get({ delayMs: 2_000 })])], decorators: [ withMockedBackend([Get({ delayMs: 2_000 }), DeleteUser({ delayMs: 3_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,6 +25,8 @@ 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 = () => "ユーザ一覧";
-
@@ -35,12 +45,22 @@ </Empty.Root>); }; export const Body: FC = () => { interface BodyProps { loginUser: User; } export const Body: FC<BodyProps> = ({ loginUser }) => { const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const [isDeleting, setIsDeleting] = useState(false); const users = useQuery({ queryKey: [WorkspaceService.typeName, WorkspaceService.method.get.name], queryKey: [ WorkspaceService.typeName, WorkspaceService.method.get.name, import.meta.url, ], async queryFn() { const resp = await client.get({}); if (resp.result.case === "ok") {
-
@@ -114,12 +134,46 @@ />)} <Flex direction="column" gap="4" role="list"> {users.data.map((user, i) => { const deletable = user.id?.value === loginUser.id?.value || user.isAdmin ? loginUser.isAdmin : !!loginUser.permissions?.canDeleteRegularUser; return ( <Fragment key={user.id?.value}> {i > 0 && <Separator size="4" />} <Flex direction="column" gap="2" role="listitem"> <Text weight="bold">{user.displayName}</Text> </Flex> <DeleteDialog.Root> <DropdownMenu.Root> <Flex gap="2" align="center" justify="between" role="listitem"> <Text weight="bold">{user.displayName}</Text> <DropdownMenu.Trigger disabled={users.isFetching}> <Button variant="soft"> 操作 <DropdownMenu.TriggerIcon /> </Button> </DropdownMenu.Trigger> </Flex> <DropdownMenu.Content> <DeleteDialog.Trigger> <DropdownMenu.Item color="red" disabled={!deletable || users.isFetching || isDeleting} > 削除 </DropdownMenu.Item> </DeleteDialog.Trigger> </DropdownMenu.Content> </DropdownMenu.Root> <DeleteDialog.Content user={user} onDeleteStart={() => void setIsDeleting(true)} onDeleted={() => { setIsDeleting(false); users.refetch(); }} onDeleteError={() => void setIsDeleting(false)} /> </DeleteDialog.Root> </Fragment> ); })}
-
@@ -145,7 +199,7 @@ }user={loginUser} > <Container p="2" size="2"> {hasAccess(loginUser) ? <Body /> : <NoAccess />} {hasAccess(loginUser) ? <Body loginUser={loginUser} /> : <NoAccess />} </Container> </LoggedInLayout> <HelpDialog.Content>
-