Changes
5 changed files (+407/-0)
-
-
@@ -3,6 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-onlyimport { type MessageInitShape } from "@bufbuild/protobuf"; import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { type CreateLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_response_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v1/workspace_service_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js";
-
@@ -203,3 +204,55 @@ },options, ); } export interface CreateLeaveDefinitionOptions extends MockOptions { failureRate?: number; error?: Exclude< MessageInitShape<typeof CreateLeaveDefinitionResponseSchema>["result"], { case: "ok" | undefined } >; } export function CreateLeaveDefinition({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is sample error message", }, }, ...options }: CreateLeaveDefinitionOptions = {}) { return mock( WorkspaceService.method.createLeaveDefinition, (req) => { if (Math.random() < failureRate) { return { result: error, }; } const id = "lv-" + crypto.randomUUID(); return { result: { case: "ok" as const, value: { id: { value: id }, displayName: req.leaveDefinition?.displayName, revisions: req.leaveDefinition?.revisions.map((rev) => { return { revisionId: { value: "lv-" + crypto.randomUUID() }, startAt: rev.startAt, snapshot: rev.snapshot, }; }), }, }, }; }, options, ); }
-
-
-
@@ -24,6 +24,7 @@import * as workers from "./workers/page.tsx"; import * as workersNew from "./workers/new/page.tsx"; import * as leaveDefinitions from "./leave-definitions/page.tsx"; import * as leaveDefinitionsNew from "./leave-definitions/new/page.tsx"; import css from "./Layout.module.css";
-
@@ -118,6 +119,13 @@ <a href={`/${workspace.id?.value}/leave-definitions`}>休暇・休業種別一覧 </a> </NavigationMenu.Item> {workspace.createLeaveDefinitionKey && ( <NavigationMenu.Item current={leaveDefinitionsNew.pattern.test(url)}> <a href={leaveDefinitionsNew.createHref(workspace)}> <leaveDefinitionsNew.Title /> </a> </NavigationMenu.Item> )} </NavigationMenu.Group> </NavigationMenu.Root> </ScrollArea>
-
-
-
@@ -0,0 +1,108 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { CreateLeaveDefinition } from "../../../../mocks/yamori/workspace/v1/workspace_service.ts"; import { Page } from "./page.tsx"; const workspace = create(WorkspaceSchema, { id: { value: "ws-foo" }, createLeaveDefinitionKey: { key: new Uint8Array([]) }, }); export default { component: Page, parameters: { layout: "fullscreen", }, args: { workspace, }, decorators: [ withInmemoryRouter({ initialURL: "/ws-foo/leave-definitions/new" }), withMockedBackend([CreateLeaveDefinition()]), ], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const WithoutCapability: Story = { args: { workspace: create(WorkspaceSchema, { id: { value: "ws-foo" }, }), }, }; export const Success: Story = {}; export const SlowLoad: Story = { decorators: [withMockedBackend([CreateLeaveDefinition({ delayMs: 2_000 })])], }; export const SystemError: Story = { decorators: [ withMockedBackend([CreateLeaveDefinition({ delayMs: 1_000, failureRate: 1 })]), ], }; export const Flaky: Story = { decorators: [ withMockedBackend([CreateLeaveDefinition({ delayMs: 1_000, failureRate: 0.5 })]), ], }; export const CapabilityError: Story = { decorators: [ withMockedBackend([ CreateLeaveDefinition({ failureRate: 1, error: { case: "capabilityError", value: { path: "create_leave_definition_key", }, }, }), ]), ], }; // TODO: Handle error at ManagerErrorCallout export const NotFoundError: Story = { decorators: [ withMockedBackend([ CreateLeaveDefinition({ failureRate: 1, error: { case: "notFound", value: { typeName: "yamori.workspace.v1.Workspace", }, }, }), ]), ], }; export const MissingField: Story = { decorators: [ withMockedBackend([ CreateLeaveDefinition({ failureRate: 1, error: { case: "missingField", value: { path: "leave_definition.display_name", }, }, }), ]), ], };
-
-
-
@@ -0,0 +1,232 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../../polyfill.ts"; import { CheckCircledIcon } from "@radix-ui/react-icons"; import { Button, Checkbox, Flex, Text, TextField } from "@radix-ui/themes"; import { CreateLeaveDefinitionRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_request_pb.js"; import { CreateLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_response_pb.js"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type FC, use } from "react"; import { Controller, useForm } from "react-hook-form"; import * as Empty from "../../../../components/Empty.ts"; import { ManagedErrorCallout } from "../../../../components/ErrorCallout.ts"; import * as FormField from "../../../../components/FormField.ts"; import { useMethodMutation } from "../../../../contexts/Service.tsx"; import { NavigationContext } from "../../../../contexts/Router.tsx"; import { useToast } from "../../../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../../../errors/IllegalMessageError.ts"; import { Layout } from "../../Layout.tsx"; export const Title: FC = () => "休暇・休業種別登録"; interface BodyProps { workspace: Workspace; } const Body: FC<BodyProps> = ({ workspace }) => { const navigation = use(NavigationContext); const toast = useToast(); const now = new Date(); const form = useForm<{ displayName: string; isWorkerDeemedToBeWorked: boolean; startAt: { year: number; month: number; day: number; }; }>({ defaultValues: { displayName: "", isWorkerDeemedToBeWorked: true, startAt: { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate(), }, }, mode: "onBlur", }); const creation = useMethodMutation({ service: "yamori.workspace.v1.WorkspaceService", method: "CreateLeaveDefinition", request: { schema: CreateLeaveDefinitionRequestSchema, }, response: { schema: CreateLeaveDefinitionResponseSchema, }, mapResponse(resp) { if (resp.result.case === "ok") { return resp.result.value; } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, options: { onSuccess(leave) { toast.open({ icon: <CheckCircledIcon />, severity: "success", title: "休暇・休業種別を登録しました", description: `「${leave.displayName}」をワークスペースに登録しました。`, type: "foreground", }); navigation.push(`/${workspace.id?.value}/leave-definitions`); }, }, }); if (!workspace.createLeaveDefinitionKey) { return ( <Empty.Root> <Empty.Title>権限がありません</Empty.Title> <Empty.Description> このワークスペース上に休暇・休業を登録する権限がありません。 </Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href={`/${workspace.id?.value}/leave-definitions`}>一覧へ</a> </Button> </Empty.Actions> </Empty.Root> ); } return ( <Flex asChild direction="column" gap="5" mt="2"> <form onSubmit={form.handleSubmit((values) => { creation.mutate({ workspaceId: workspace.id, leaveDefinition: { displayName: values.displayName, revisions: [ { startAt: values.startAt, snapshot: { isWorkerDeemedToBeWorked: values.isWorkerDeemedToBeWorked, }, }, ], }, createLeaveDefinitionKey: workspace.createLeaveDefinitionKey, }); })} > {creation.error ? ( <ManagedErrorCallout error={creation.error} title="登録に失敗しました" actions={ <Button size="1" variant="outline" type="button" onClick={() => void creation.reset()} > 閉じる </Button> } /> ) : ( <div /> )} <FormField.Root> <FormField.Label htmlFor="display_name_id">休暇・休業名</FormField.Label> <TextField.Root id="display_name_id" disabled={creation.isPending} placeholder="リフレッシュ休暇" color={form.formState.errors.displayName ? "red" : undefined} aria-invalid={!!form.formState.errors.displayName} autoComplete="off" {...form.register("displayName", { required: "休暇・休業名は必須です", setValueAs(value: string) { return value.trim(); }, })} /> <FormField.Description error={form.formState.errors.displayName?.message}> 登録する休暇・休業の名前です。前後の空白は自動的に取り除かれます。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label>有給休暇計算時の扱い</FormField.Label> <Flex asChild gap="2"> <Text as="label" size="2"> <Controller control={form.control} name="isWorkerDeemedToBeWorked" render={({ field }) => { return ( <Checkbox ref={field.ref} disabled={creation.isPending} checked={field.value} name={field.name} onCheckedChange={field.onChange} onBlur={field.onBlur} /> ); }} /> 出勤したとみなす </Text> </Flex> <FormField.Description> 有効にすると、有給休暇を付与する際の出勤率計算時にこの休暇・休業を行った日を 出勤したものとみなします。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="startAt">運用開始日</FormField.Label> {/* TODO: Date picker */} <FormField.Description error={form.formState.errors.startAt?.message}> この休暇・休業をいつの勤怠記録上から選択できるようにするか指定します。 </FormField.Description> </FormField.Root> <Button mt="5" loading={creation.isPending}> 登録 </Button> </form> </Flex> ); }; export interface PageProps { workspace: Workspace; } export const Page: FC<PageProps> = ({ workspace }) => { return ( <Layout workspace={workspace} title={<Title />}> <Body workspace={workspace} /> </Layout> ); }; export function createHref(workspace: Workspace): string { if (!workspace.id) { return "/"; } return `/${workspace.id.value}/leave-definitions/new`; } export const pattern = new URLPattern({ pathname: "/:workspace/leave-definitions/new", });
-
-
-
@@ -20,6 +20,7 @@import * as workers from "./workers/page.tsx"; import * as workersNew from "./workers/new/page.tsx"; import * as leaveDefinitions from "./leave-definitions/page.tsx"; import * as leaveDefinitionsNew from "./leave-definitions/new/page.tsx"; import { Layout } from "./Layout.tsx"; export const Page: FC = () => {
-
@@ -38,6 +39,7 @@ fields: [WorkspaceSchema.field.id.number, WorkspaceSchema.field.displayName.number, WorkspaceSchema.field.workerAddKey.number, WorkspaceSchema.field.createLeaveDefinitionKey.number, ], }, },
-
@@ -126,6 +128,10 @@ },{ pattern: leaveDefinitions.pattern, children: <leaveDefinitions.Page workspace={workspace} />, }, { pattern: leaveDefinitionsNew.pattern, children: <leaveDefinitionsNew.Page workspace={workspace} />, }, ]} fallback={
-