Changes
6 changed files (+320/-1)
-
-
@@ -288,3 +288,50 @@ },options, ); } export interface PutCustomAttributeDefinitionOptions extends MockOptions { failureRate?: number; error?: Exclude< MessageInitShape< typeof WorkspaceService.method.putCustomAttributeDefinition.output >["result"], { case: "ok" | undefined } >; } export function PutCustomAttributeDefinition({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is a sample error", }, }, ...options }: PutCustomAttributeDefinitionOptions = {}) { return mock( WorkspaceService.method.putCustomAttributeDefinition, (req) => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: { id: req.id ?? { value: "cf-" + crypto.randomUUID(), }, displayName: req.displayName, }, }, }; }, options, ); }
-
-
-
@@ -22,6 +22,7 @@ import * as NavigationMenu from "../components/NavigationMenu.ts";import { URLContext } from "../contexts/Router.tsx"; import { useViewTransition } from "../hooks/useViewTransition.ts"; import * as customAttributeNew from "./custom-attribute/new/page.tsx"; import * as customAttributes from "./custom-attributes/page.tsx"; import * as userNew from "./user/new/page.tsx"; import * as users from "./users/page.tsx";
-
@@ -142,6 +143,13 @@ <a href={customAttributes.createHref()}><customAttributes.Title /> </a> </NavigationMenu.Item> {customAttributeNew.hasAccess(user) && ( <NavigationMenu.Item current={customAttributeNew.pattern.test(url)}> <a href={customAttributeNew.createHref()}> <customAttributeNew.Title /> </a> </NavigationMenu.Item> )} </NavigationMenu.Group> </NavigationMenu.Root> </ScrollArea>
-
-
-
@@ -0,0 +1,68 @@// 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, PutCustomAttributeDefinition, } from "../../../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page } from "./page.tsx"; export default { component: Page, decorators: [withInmemoryRouter({ initialURL: "/custom-attribute/new" })], parameters: { layout: "fullscreen", }, args: { loginUser: alice, }, } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([PutCustomAttributeDefinition()])], async play({ canvas }) { 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,182 @@// 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, Flex, TextField } from "@radix-ui/themes"; import { useMutation } from "@tanstack/react-query"; 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 { onCreated?(): void; } const Body: FC<BodyProps> = ({ onCreated }) => { 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) { onCreated?.(); toast.open({ severity: "success", title: `カスタムフィールド定義「${def.displayName}」を作成しました`, dismissible: true, type: "foreground", }); }, }); const form = useForm<{ displayName: string; }>({ defaultValues: { 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({ 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> <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(); }, })} /> <FormField.Description error={form.formState.errors.displayName?.message}> カスタムフィールドの名前です。 前後の空白は無視されます。 </FormField.Description> </FormField.Root> <Button loading={creation.isPending}>作成</Button> </form> </Flex> </Flex> ); }; export interface PageProps { loginUser: User; } export const Page: FC<PageProps> = ({ loginUser }) => { const navigation = use(NavigationContext); return ( <LoggedInLayout title={<Title />} user={loginUser}> <Container p="2" size="2"> {hasAccess(loginUser) ? ( <Body onCreated={() => { navigation.push("/custom-attributes"); }} /> ) : ( <NoAccess /> )} </Container> </LoggedInLayout> ); }; export function createHref(): string { return "/custom-attribute/new"; } export const pattern = new URLPattern({ pathname: "/custom-attribute/new", });
-
-
-
@@ -10,6 +10,7 @@ CreateUser,Get, GetLoginUser, Login, PutCustomAttributeDefinition, } from "../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page } from "./page.tsx";
-
@@ -19,7 +20,15 @@ component: Page,parameters: { layout: "fullscreen", }, decorators: [withMockedBackend([CreateUser(), GetLoginUser(), Login(), Get()])], decorators: [ withMockedBackend([ CreateUser(), GetLoginUser(), Login(), Get(), PutCustomAttributeDefinition(), ]), ], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>;
-
-
-
@@ -10,6 +10,7 @@import * as Empty from "../components/Empty.ts"; 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 userNew from "./user/new/page.tsx"; import * as users from "./users/page.tsx";
-
@@ -33,6 +34,10 @@ },{ pattern: users.pattern, children: <users.Page loginUser={loginUser} />, }, { pattern: customAttributeNew.pattern, children: <customAttributeNew.Page loginUser={loginUser} />, }, { pattern: customAttributes.pattern,
-