Changes
7 changed files (+328/-0)
-
-
@@ -12,6 +12,7 @@ } from "../../../../../.storybook/decorators/withMockedBackend.tsx";export const alice = create(UserSchema, { id: { value: "wu-alice" }, name: "alice", displayName: "Alice", isAdmin: true, permissions: {
-
@@ -30,6 +31,7 @@ });export const bob = create(UserSchema, { id: { value: "wu-bob" }, name: "bob", displayName: "Bob", permissions: { canUpdateSelfProfile: true,
-
@@ -171,6 +173,53 @@ ...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 UpdateUserOptions extends MockOptions { failureRate?: number; ok?: Extract< MessageInitShape<typeof WorkspaceService.method.updateUser.output>["result"], { case: "ok" } >["value"]; error?: Exclude< MessageInitShape<typeof WorkspaceService.method.updateUser.output>["result"], { case: "ok" | undefined } >; } export function UpdateUser({ failureRate = 0, ok = bob, // TODO: モックのエラーはほぼ全てコピペなので定数化する error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is a sample error", }, }, ...options }: UpdateUserOptions = {}) { return mock( WorkspaceService.method.updateUser, (_req) => { if (Math.random() < failureRate) { return {
-
-
-
@@ -12,6 +12,7 @@ Get,GetLoginUser, Login, PutCustomAttributeDefinition, UpdateUser, } from "../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page } from "./page.tsx";
-
@@ -29,6 +30,7 @@ GetLoginUser(),Login(), Get(), PutCustomAttributeDefinition(), UpdateUser(), ]), ], } satisfies Meta<typeof Page>;
-
-
-
@@ -11,6 +11,7 @@ import { NavigationContext } from "../../../contexts/Router.tsx";import { LoggedInLayout } from "../../LoggedInLayout.tsx"; import * as DeleteDialog from "../DeleteDialog.tsx"; import * as list from "../page.tsx"; import * as edit from "./edit/page.tsx"; const Title: FC = () => "ユーザ詳細";
-
@@ -48,6 +49,9 @@ <Heading as="h2" mt="3">操作 </Heading> <Flex wrap="wrap" gap="2" align="center"> <Button disabled={!edit.hasAccess(loginUser, user)} variant="soft" asChild> <a href={edit.createHref({ user })}>編集</a> </Button> <DeleteDialog.Root> <DeleteDialog.Trigger> <Button
-
-
-
@@ -0,0 +1,79 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { userEvent } from "@storybook/test"; import { withMockedBackend } from "../../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { alice, bob, UpdateUser, } from "../../../../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page, createHref } from "./page.tsx"; export default { component: Page, decorators: [withInmemoryRouter({ initialURL: createHref({ user: bob }) })], parameters: { layout: "fullscreen", }, args: { loginUser: alice, user: bob, }, } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([UpdateUser()])], async play({ canvas }) { await userEvent.clear(canvas.getByLabelText("表示名")); await userEvent.type(canvas.getByLabelText("表示名"), "ボビー"); await userEvent.click(canvas.getByRole("button", { name: "更新" })); }, }; export const NoPermission: Story = { args: { loginUser: bob, user: alice, }, }; export const SlowLoad: Story = { decorators: [withMockedBackend([UpdateUser({ delayMs: 2_000 })])], play: Success.play, }; export const SystemError: Story = { decorators: [withMockedBackend([UpdateUser({ failureRate: 1 })])], play: Success.play, }; export const DuplicatedNameError: Story = { decorators: [ withMockedBackend([ UpdateUser({ error: { case: "duplicatedName", value: "Foo", }, failureRate: 1, }), ]), ], play: Success.play, }; export const RestrictedPermissions: Story = { args: { loginUser: bob, user: bob, }, decorators: [withMockedBackend([UpdateUser()])], };
-
-
-
@@ -0,0 +1,178 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../../polyfill.ts"; import { type MessageInitShape } from "@bufbuild/protobuf"; import { createClient } from "@connectrpc/connect"; import { Button, Box, Container } from "@radix-ui/themes"; import { useMutation } from "@tanstack/react-query"; import { type UpdateUserRequestSchema } from "@yamori/proto/yamori/workspace/v2/update_user_request_pb.js"; 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, use } from "react"; import * as Empty from "../../../../components/Empty.ts"; import { ManagedErrorCallout } from "../../../../components/ErrorCallout.ts"; import { UpdateForm } from "../../../../components/UserEditForm.tsx"; import { useConnectTransport } from "../../../../contexts/ConnectTransport.tsx"; import { NavigationContext } from "../../../../contexts/Router.tsx"; import { useToast } from "../../../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../../../errors/IllegalMessageError.ts"; import { UserInputError } from "../../../../errors/UserInputError.ts"; import { LoggedInLayout } from "../../../LoggedInLayout.tsx"; import * as details from "../Details.tsx"; export const Title: FC = () => "ユーザ編集"; export function hasAccess(loginUser: User, user: User): boolean { return !!(loginUser.id?.value === user.id?.value ? loginUser.permissions?.canUpdateSelfProfile : user.isAdmin ? loginUser.isAdmin : loginUser.permissions?.canUpdateOtherRegularUserProfile); } interface NoAccessProps { user: User; } const NoAccess: FC<NoAccessProps> = ({ user }) => { return ( <Empty.Root> <Empty.Title>編集権限がありません</Empty.Title> <Empty.Description> 「{user.displayName}」に対する編集権限がないためページを表示できません。 </Empty.Description> <Empty.Actions> <Button asChild> <a href={details.createHref({ user })}>ユーザ詳細画面へ</a> </Button> </Empty.Actions> </Empty.Root> ); }; interface BodyProps { loginUser: User; user: User; onUpdated?(): void; } const Body: FC<BodyProps> = ({ user, loginUser, onUpdated }) => { const toast = useToast(); const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const update = useMutation({ async mutationFn(req: MessageInitShape<typeof UpdateUserRequestSchema>) { const resp = await client.updateUser(req); if (resp.result.case === "ok") { return resp.result.value; } if (resp.result.case === "duplicatedName") { throw new UserInputError("ユーザ名が同一の別ユーザが既に存在します"); } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, onSuccess(user) { onUpdated?.(); toast.open({ severity: "success", title: `ユーザ「${user.displayName}」を更新しました`, dismissible: true, type: "foreground", }); }, }); return ( <Box mt="3"> {update.error ? ( <Box position="sticky" top="0" pt="2" mb="2" style={{ zIndex: 10 }}> <ManagedErrorCallout style={{ backdropFilter: "blur(2em)" }} role="alert" error={update.error} actions={ <Button size="1" variant="outline" type="button" onClick={() => void update.reset()} > 閉じる </Button> } title="更新に失敗しました" /> </Box> ) : ( <Box mb="2" /> )} <UpdateForm pending={update.isPending} loginUser={loginUser} user={user} onUpdate={({ name, displayName, ...permissions }) => { update.mutate({ id: user.id, name, displayName, permissions }); }} /> </Box> ); }; export interface PageProps { loginUser: User; user: User; } export const Page: FC<PageProps> = ({ loginUser, user }) => { const navigation = use(NavigationContext); return ( <LoggedInLayout title={<Title />} user={loginUser}> <Container p="2" size="2"> {hasAccess(loginUser, user) ? ( <Body loginUser={loginUser} user={user} onUpdated={() => { navigation.push(details.createHref({ user })); }} /> ) : ( <NoAccess user={user} /> )} </Container> </LoggedInLayout> ); }; export interface CreateHrefInput { user: User; } export function createHref({ user }: CreateHrefInput): string { if (!user.id) { return "/users"; } return `/users/${user.id.value}/edit`; } export const pattern = new URLPattern({ pathname: "/users/:user/edit", });
-
-
-
@@ -18,6 +18,7 @@ import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts";import { LoggedInLayout } from "../../LoggedInLayout.tsx"; import * as list from "../page.tsx"; import * as edit from "./edit/page.tsx"; import * as details from "./Details.tsx"; const Title: FC = () => "ユーザ詳細";
-
@@ -142,6 +143,10 @@ routes={[{ pattern: details.pattern, children: <details.Page user={user} loginUser={loginUser} />, }, { pattern: edit.pattern, children: <edit.Page user={user} loginUser={loginUser} />, }, ]} fallback={
-
-
-
@@ -28,6 +28,7 @@import { LoggedInLayout } from "../LoggedInLayout.tsx"; import * as userDetails from "./:id/Details.tsx"; import * as userEdit from "./:id/edit/page.tsx"; import * as DeleteDialog from "./DeleteDialog.tsx"; export const Title: FC = () => "ユーザ一覧";
-
@@ -167,6 +168,16 @@ </Flex><DropdownMenu.Content> <DropdownMenu.Item asChild> <a href={userDetails.createHref({ user })}>詳細</a> </DropdownMenu.Item> <DropdownMenu.Item asChild disabled={ !userEdit.hasAccess(loginUser, user) || users.isFetching || isDeleting } > <a href={userEdit.createHref({ user })}>編集</a> </DropdownMenu.Item> <DeleteDialog.Trigger> <DropdownMenu.Item
-