Changes
5 changed files (+441/-0)
-
-
@@ -0,0 +1,79 @@// SPDX-FileCopyrightText: 2025 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 { userEvent } from "@storybook/test"; import { CustomAttributeDefinitionSchema } from "@yamori/proto/yamori/workspace/v2/custom_attribute_definition_pb.js"; import { withMockedBackend } from "../../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { alice, bob, PutCustomAttributeDefinition, } from "../../../../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page, createHref } from "./page.tsx"; const definition = create(CustomAttributeDefinitionSchema, { id: { value: "cf-foo", }, displayName: "Foo", }); export default { component: Page, decorators: [withInmemoryRouter({ initialURL: createHref({ definition }) })], parameters: { layout: "fullscreen", }, args: { loginUser: alice, definition, }, } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([PutCustomAttributeDefinition()])], async play({ canvas }) { await userEvent.clear(canvas.getByLabelText("表示名")); await userEvent.type(canvas.getByLabelText("表示名"), "庁舎ログインID"); await userEvent.click(canvas.getByRole("button", { name: "更新" })); }, }; export const NoPermission: Story = { args: { loginUser: bob, }, }; export const SlowLoad: Story = { decorators: [withMockedBackend([PutCustomAttributeDefinition({ delayMs: 2_000 })])], play: Success.play, }; export const SystemError: Story = { decorators: [withMockedBackend([PutCustomAttributeDefinition({ failureRate: 1 })])], play: Success.play, }; export const DuplicatedNameError: Story = { decorators: [ withMockedBackend([ PutCustomAttributeDefinition({ error: { case: "duplicatedDisplayName", value: "Foo", }, failureRate: 1, }), ]), ], play: Success.play, };
-
-
-
@@ -0,0 +1,209 @@// 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 { ArrowDownIcon } from "@radix-ui/react-icons"; import { Button, Box, Container, Flex, TextField } from "@radix-ui/themes"; import { useMutation } from "@tanstack/react-query"; import { type CustomAttributeDefinition } from "@yamori/proto/yamori/workspace/v2/custom_attribute_definition_pb.js"; import { type PutCustomAttributeDefinitionRequestSchema } from "@yamori/proto/yamori/workspace/v2/put_custom_attribute_definition_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 { useForm } from "react-hook-form"; import * as Empty from "../../../../components/Empty.ts"; import * as FormField from "../../../../components/FormField.ts"; import { ManagedErrorCallout } from "../../../../components/ErrorCallout.ts"; 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"; export const Title: FC = () => "カスタムフィールド定義編集"; export function hasAccess(loginUser: User): boolean { return !!loginUser.permissions?.canUpdateWorkspace; } export const NoAccess: FC = () => { return ( <Empty.Root> <Empty.Title>編集権限がありません</Empty.Title> <Empty.Description> ワークスペースに対する操作権限がないためページを表示できません。 </Empty.Description> </Empty.Root> ); }; interface BodyProps { definition: CustomAttributeDefinition; onUpdate?(): void; } const Body: FC<BodyProps> = ({ definition, onUpdate }) => { const toast = useToast(); const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const creation = useMutation({ async mutationFn( req: MessageInitShape<typeof PutCustomAttributeDefinitionRequestSchema>, ) { const resp = await client.putCustomAttributeDefinition(req); if (resp.result.case === "ok") { return resp.result.value; } if (resp.result.case === "duplicatedDisplayName") { throw new UserInputError("表示名が同一の定義が既に存在します"); } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, onSuccess(def) { onUpdate?.(); toast.open({ severity: "success", title: `カスタムフィールド定義「${def.displayName}」を更新しました`, dismissible: true, type: "foreground", }); }, }); const form = useForm<{ displayName: string; }>({ defaultValues: { displayName: definition.displayName, }, mode: "onBlur", }); return ( <Flex direction="column" mt="5" gap="5"> <Flex asChild direction="column" gap="5" mt="2"> <form onSubmit={form.handleSubmit(({ displayName }) => { creation.mutate({ id: definition.id, displayName, }); })} > {creation.error ? ( <Box position="sticky" top="0" pt="2"> <ManagedErrorCallout style={{ backdropFilter: "blur(2em)" }} role="alert" error={creation.error} actions={ <Button size="1" variant="outline" type="button" onClick={() => void creation.reset()} > 閉じる </Button> } title="更新に失敗しました" /> </Box> ) : ( <div /> )} <FormField.Root> <FormField.Label htmlFor="c_displayName">表示名</FormField.Label> <Flex direction="column" gap="1"> <TextField.Root readOnly autoComplete="off" value={definition.displayName} aria-label="変更前の表示名" /> <Flex justify="center"> <ArrowDownIcon /> </Flex> <TextField.Root id="c_displayName" disabled={creation.isPending} placeholder="社員番号" color={form.formState.errors.displayName ? "red" : undefined} aria-invalid={!!form.formState.errors.displayName} autoComplete="off" {...form.register("displayName", { setValueAs(value: string) { return value.trim(); }, })} /> </Flex> <FormField.Description error={form.formState.errors.displayName?.message}> カスタムフィールドの名前です。 前後の空白は無視されます。 </FormField.Description> </FormField.Root> <Button loading={creation.isPending}>更新</Button> </form> </Flex> </Flex> ); }; export interface PageProps { definition: CustomAttributeDefinition; loginUser: User; } export const Page: FC<PageProps> = ({ definition, loginUser }) => { const navigation = use(NavigationContext); return ( <LoggedInLayout title={<Title />} user={loginUser}> <Container p="2" size="2"> {hasAccess(loginUser) ? ( <Body definition={definition} onUpdate={() => { navigation.push("/custom-attributes"); }} /> ) : ( <NoAccess /> )} </Container> </LoggedInLayout> ); }; export interface CreateHrefInput { definition: CustomAttributeDefinition; } export function createHref({ definition }: CreateHrefInput): string { if (!definition.id) { return "/custom-attributes/"; } return `/custom-attributes/${definition.id.value}/edit`; } export const pattern = new URLPattern({ pathname: "/custom-attributes/:id/edit", });
-
-
-
@@ -0,0 +1,143 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../polyfill.ts"; import { createClient } from "@connectrpc/connect"; import { Button, Code, Container, Flex, 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 } from "react"; import * as Empty from "../../../components/Empty.ts"; import { ManagedErrorCallout } from "../../../components/ErrorCallout.ts"; import { useConnectTransport } from "../../../contexts/ConnectTransport.tsx"; import { Select, useURLPatternResult } from "../../../contexts/Router.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import * as edit from "./edit/page.tsx"; import * as list from "../page.tsx"; import { LoggedInLayout } from "../../LoggedInLayout.tsx"; export const Title: FC = () => "カスタムフィールド定義"; export interface PageProps { loginUser: User; } export const Page: FC<PageProps> = ({ loginUser }) => { const { pathname } = useURLPatternResult(); const defId = pathname.groups.definition; const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const query = useQuery({ queryKey: [ WorkspaceService.typeName, WorkspaceService.method.get.name, import.meta.url, defId, ], async queryFn() { const resp = await client.get({}); if (resp.result.case === "ok") { return resp.result.value.customAttributeDefinition.find( (def) => defId === def.id?.value, ); } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, }); if (query.isError) { return ( <LoggedInLayout title={<Title />} user={loginUser}> <Container p="3" size="2"> <Empty.Root> <Empty.Title>カスタムフィールド定義の取得に失敗</Empty.Title> <Empty.Description> カスタムフィールド定義の取得中にエラーが発生しました。 </Empty.Description> <ManagedErrorCallout title="取得失敗" error={query.error} /> <Empty.Actions> <Button size="3" onClick={() => void query.refetch()}> 再取得 </Button> <Button asChild size="3" variant="outline"> <a href={list.createHref()}>一覧画面へ戻る</a> </Button> </Empty.Actions> </Empty.Root> </Container> </LoggedInLayout> ); } if (query.isLoading) { return ( <LoggedInLayout title={<Title />} user={loginUser}> <Flex position="absolute" inset="0" align="center" justify="center" gap="2"> <Spinner /> <Text size="2" color="gray"> カスタムフィールド定義を読込中... </Text> </Flex> </LoggedInLayout> ); } if (!query.data) { return ( <LoggedInLayout title={<Title />} user={loginUser}> <Container p="3" size="2"> <Empty.Root> <Empty.Title>カスタムフィールド定義がありません</Empty.Title> <Empty.Description> ID <Code>{defId}</Code> のカスタムフィールド定義は登録されていません。 </Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href={list.createHref()}>一覧画面へ戻る</a> </Button> </Empty.Actions> </Empty.Root> </Container> </LoggedInLayout> ); } const definition = query.data; return ( <Select routes={[ { pattern: edit.pattern, children: <edit.Page definition={definition} loginUser={loginUser} />, }, ]} fallback={ <LoggedInLayout title="404" user={loginUser}> <Empty.Title>ページが見つかりません</Empty.Title> <Empty.Description>指定された URL にページはありません。</Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href={list.createHref()}>一覧へ戻る</a> </Button> </Empty.Actions> </LoggedInLayout> } /> ); }; export const pattern = new URLPattern({ pathname: "/custom-attributes/:definition(cf-[^\\/]+)/:frag*", });
-
-
-
@@ -24,6 +24,8 @@ import { ManagedErrorCallout } from "../../components/ErrorCallout.ts";import { useConnectTransport } from "../../contexts/ConnectTransport.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import * as edit from "./:id/edit/page.tsx"; import { LoggedInLayout } from "../LoggedInLayout.tsx"; import * as DeleteDialog from "./DeleteDialog.tsx";
-
@@ -144,6 +146,9 @@ </DropdownMenu.Trigger>)} </Flex> <DropdownMenu.Content> <DropdownMenu.Item asChild> <a href={edit.createHref({ definition: def })}>編集</a> </DropdownMenu.Item> <DeleteDialog.Trigger> <DropdownMenu.Item color="red">削除</DropdownMenu.Item> </DeleteDialog.Trigger>
-
-
-
@@ -12,6 +12,7 @@ import { Select } from "../contexts/Router.tsx";import * as customAttributeNew from "./custom-attribute/new/page.tsx"; import * as customAttributes from "./custom-attributes/page.tsx"; import * as customAttributesId from "./custom-attributes/:id/page.tsx"; import * as userNew from "./user/new/page.tsx"; import * as users from "./users/page.tsx"; import * as root from "./root/page.tsx";
-
@@ -38,6 +39,10 @@ },{ pattern: customAttributeNew.pattern, children: <customAttributeNew.Page loginUser={loginUser} />, }, { pattern: customAttributesId.pattern, children: <customAttributesId.Page loginUser={loginUser} />, }, { pattern: customAttributes.pattern,
-