Changes
8 changed files (+180/-464)
-
-
@@ -108,3 +108,53 @@ },options, ); } export interface GetLoginUserOptions extends MockOptions { failureRate?: number; error?: Exclude< MessageInitShape<typeof WorkspaceService.method.getLoginUser.output>["result"], { case: "ok" | undefined } >; } export function GetLoginUser({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is a sample error", }, }, ...options }: GetLoginUserOptions = {}) { return mock( WorkspaceService.method.getLoginUser, () => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: { id: { value: "ws-" + crypto.randomUUID(), }, name: "alice", displayName: "Alice", isAdmin: true, loginMethod: { passwordConfigured: true, }, }, }, }; }, options, ); }
-
-
-
@@ -1,73 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../.storybook/decorators/withInmemoryRouter.tsx"; import { Create } from "../../mocks/yamori/workspace/v1/workspace_service.ts"; import { CreationForm } from "./CreationForm.tsx"; export default { component: CreationForm, decorators: [withInmemoryRouter()], } satisfies Meta<typeof CreationForm>; type Story = StoryObj<typeof CreationForm>; export const Success: Story = { decorators: [withMockedBackend([Create()])], }; export const SlowLoad: Story = { decorators: [withMockedBackend([Create({ delayMs: 2_000 })])], }; export const MissingField: Story = { decorators: [ withMockedBackend([ Create({ failureRate: 1, error: { case: "missingField", value: { path: "display_name", }, }, delayMs: 1_000, }), ]), ], }; export const NoStorageSpace: Story = { decorators: [ withMockedBackend([ Create({ failureRate: 1, error: { case: "noStorageSpace", value: { message: "Test error", }, }, delayMs: 1_000, }), ]), ], }; export const SystemError: Story = { decorators: [ withMockedBackend([ Create({ failureRate: 1, delayMs: 1_000, }), ]), ], }; export const Playground: Story = {};
-
-
-
@@ -1,143 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { CheckCircledIcon } from "@radix-ui/react-icons"; import { Button, Flex, TextField } from "@radix-ui/themes"; import { CreateRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type FC } from "react"; import { useForm } from "react-hook-form"; import { ManagedErrorCallout } from "../../components/ErrorCallout.ts"; import * as FormField from "../../components/FormField.ts"; import { useMethodMutation } from "../../contexts/Service.tsx"; import { useToast } from "../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import * as calendarPage from "../:workspace/calendar/page.tsx"; export interface CreationFormProps { className?: string | undefined; onCreated?(): void; } export const CreationForm: FC<CreationFormProps> = ({ className, onCreated }) => { const toast = useToast(); const form = useForm<{ displayName: string; }>({ defaultValues: { displayName: "", }, mode: "onBlur", }); const creation = useMethodMutation({ service: "yamori.workspace.v1.WorkspaceService", method: "Create", request: { schema: CreateRequestSchema, }, response: { schema: CreateResponseSchema, }, mapResponse(resp) { if (resp.result.case === "ok") { if (!resp.result.value.workspace) { throw new IllegalMessageError(resp); } return resp.result.value.workspace; } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, options: { onSuccess(workspace) { onCreated?.(); const dismiss = toast.open({ icon: <CheckCircledIcon />, severity: "success", title: `「${workspace.displayName}」を作成しました`, action: ( <Button asChild size="1" onClick={() => { dismiss(); }} > <a href={calendarPage.href({ workspace })}>開く</a> </Button> ), type: "foreground", }); }, }, }); return ( <Flex asChild direction="column" gap="3" mt="2"> <form className={className} onSubmit={form.handleSubmit(({ displayName }) => { creation.mutate({ displayName, readMask: { fields: [ WorkspaceSchema.field.id.number, WorkspaceSchema.field.displayName.number, ], }, }); })} > {creation.error ? ( <ManagedErrorCallout error={creation.error} actions={ <Button size="1" variant="outline" type="button" onClick={() => void creation.reset()} > 閉じる </Button> } title="作成に失敗しました" /> ) : ( <div /> )} <FormField.Root> <FormField.Label htmlFor="displayName">表示名</FormField.Label> <TextField.Root 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> <Button loading={creation.isPending}>作成</Button> </form> </Flex> ); };
-
-
-
@@ -1,136 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { UpdateIcon, PlusIcon } from "@radix-ui/react-icons"; import { Button, Flex, Spinner, Text } from "@radix-ui/themes"; import { type UseQueryResult } from "@tanstack/react-query"; import { ListRequestSchema } from "@yamori/proto/yamori/workspace/v1/list_request_pb.js"; import { ListResponseSchema, type ListResponse_Result, } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type FC, useEffect } from "react"; import { ManagedErrorCallout } from "../../components/ErrorCallout.ts"; import { useMethodQuery } from "../../contexts/Service.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import { WorkspaceList } from "./WorkspaceList.tsx"; export interface SelectorProps { onOpenCreationForm?(): void; } export const Selector: FC<SelectorProps> = ({ onOpenCreationForm }) => { const list = useMethodQuery({ service: "yamori.workspace.v1.WorkspaceService", method: "List", request: { schema: ListRequestSchema, data: { readMask: { fields: [ WorkspaceSchema.field.id.number, WorkspaceSchema.field.displayName.number, ], }, }, }, response: { schema: ListResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value; case "systemError": throw resp.result.value; default: throw new IllegalMessageError(resp); } }, }); useEffect(() => { if (list.isFetching || list.data?.workspaces.length !== 0) { return; } onOpenCreationForm?.(); }, [list.isFetching, list.data?.workspaces.length]); return ( <Flex direction="column"> <Flex justify="end" align="center" gap="2" p="2"> <Button variant="outline" size="1" loading={list.isFetching} onClick={() => void list.refetch()} > <UpdateIcon /> 更新 </Button> <Button variant="outline" size="1" onClick={() => void onOpenCreationForm?.()}> <PlusIcon /> 作成 </Button> </Flex> <Contents list={list} /> </Flex> ); }; interface ContentsProps { list: UseQueryResult<ListResponse_Result, unknown>; } const Contents: FC<ContentsProps> = ({ list }) => { if (list.status === "pending") { return ( <Flex align="center" justify="center" pt="7" gap="2"> <Spinner /> <Text size="2" color="gray"> ワークスペースを読込中... </Text> </Flex> ); } if (!list.data) { return ( <ManagedErrorCallout title="一覧の取得に失敗しました" error={list.error} actions={ <Button size="1" loading={list.isFetching} onClick={() => void list.refetch()}> 再取得 </Button> } /> ); } return ( <Flex direction="column" gap="5"> {!!list.error && ( <ManagedErrorCallout severity="warning" title="最新の一覧の取得に失敗しました" error={list.error} actions={ <Button size="1" loading={list.isFetching} onClick={() => void list.refetch()} > 再試行 </Button> } /> )} <WorkspaceList workspaces={list.data.workspaces} /> </Flex> ); };
-
-
-
@@ -1,35 +0,0 @@// SPDX-FileCopyrightText: 2024 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 { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { withInmemoryRouter } from "../../../.storybook/decorators/withInmemoryRouter.tsx"; import { WorkspaceList } from "./WorkspaceList.tsx"; export default { component: WorkspaceList, decorators: [withInmemoryRouter({ initialURL: "/" })], args: { workspaces: [ create(WorkspaceSchema, { id: { value: "ws-foo", }, displayName: "Foo", }), create(WorkspaceSchema, { id: { value: "ws-bar", }, displayName: "Bar", }), ], }, } satisfies Meta<typeof WorkspaceList>; type Story = StoryObj<typeof WorkspaceList>; export const Default: Story = {};
-
-
-
@@ -1,33 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Box, Button, Card, Flex, Text } from "@radix-ui/themes"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type FC } from "react"; import * as calendarPage from "../:workspace/calendar/page.tsx"; export interface WorkspaceListProps { workspaces: readonly Workspace[]; } export const WorkspaceList: FC<WorkspaceListProps> = ({ workspaces }) => { return ( <Flex role="list" direction="column" gap="3"> {workspaces.map((workspace) => ( <Card key={workspace.id?.value} role="listitem"> <Flex direction="column" gap="2"> <Box> <Text weight="bold">{workspace.displayName}</Text> </Box> <Flex justify="end"> <Button asChild variant="soft"> <a href={calendarPage.href({ workspace })}>開く</a> </Button> </Flex> </Flex> </Card> ))} </Flex> ); };
-
-
-
@@ -1,12 +1,17 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../.storybook/decorators/withInmemoryRouter.tsx"; import { ThirdPartyNoticeProvider } from "../../components/CopyrightNotice.ts"; import { Create, List } from "../../mocks/yamori/workspace/v1/workspace_service.ts"; import { CreateInitialAdmin, GetLoginUser, Login, } from "../../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page } from "./page.tsx";
-
@@ -15,6 +20,9 @@ component: Page,parameters: { layout: "fullscreen", }, args: { onLogin: action("onLogin"), }, decorators: [ withInmemoryRouter({ initialURL: "/" }), (Story) => (
-
@@ -27,31 +35,45 @@ } satisfies Meta<typeof Page>;type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([List(), Create()])], }; export const SlowLoad: Story = { decorators: [withMockedBackend([List({ delayMs: 2_000 }), Create({ delayMs: 1_000 })])], }; export const NoWorkspaces: Story = { decorators: [withMockedBackend([List({ workspaces: [] }), Create()])], export const LoggedIn: Story = { decorators: [withMockedBackend([GetLoginUser()])], }; export const ListLoadError: Story = { export const NotLoggedIn: Story = { decorators: [ withMockedBackend([List({ failureRate: 1, delayMs: 200 }), Create({ delayMs: 200 })]), withMockedBackend([ GetLoginUser({ failureRate: 1, error: { case: "authenticationError", value: {}, }, }), Login(), ]), ], }; export const Flaky: Story = { export const NoAdmin: Story = { decorators: [ withMockedBackend([ List({ failureRate: 0.5, delayMs: 200 }), Create({ failureRate: 0.5, delayMs: 200 }), GetLoginUser({ failureRate: 1, error: { case: "noUserInWorkspace", value: {}, }, }), Login(), CreateInitialAdmin(), ]), ], }; export const Playground: Story = {}; export const SlowLoad: Story = { decorators: [withMockedBackend([GetLoginUser({ delayMs: 2_000 })])], }; export const SystemError: Story = { decorators: [withMockedBackend([GetLoginUser({ failureRate: 1 })])], };
-
-
-
@@ -3,44 +3,108 @@ // SPDX-License-Identifier: AGPL-3.0-onlyimport "../../polyfill.ts"; import { type FC, useCallback, useState } from "react"; import { type MessageShape } from "@bufbuild/protobuf"; import { createClient } from "@connectrpc/connect"; import { UserSchema } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type FC, useCallback, useEffect, useRef, useState } from "react"; import { useConnectTransport } from "../../contexts/ConnectTransport.tsx"; import { useToast } from "../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import { useViewTransition } from "../../hooks/useViewTransition.ts"; import { Layout } from "./Layout.tsx"; import { Selector } from "./Selector.tsx"; import { CreationForm } from "./CreationForm.tsx"; import { InitialAdminCreation } from "./InitialAdminCreation.tsx"; import { Loading } from "./Loading.tsx"; import { Login } from "./Login.tsx"; export const Page: FC = () => { const enum State { Loading = 0, NoAdminInWorkspace, RequireLogin, } export interface PageProps { onLogin?(user: MessageShape<typeof UserSchema>): void; } export const Page: FC<PageProps> = ({ onLogin }) => { const viewTransition = useViewTransition(); const toast = useToast(); const [isOpeningCreationForm, setIsOpeningCreationForm] = useState(false); const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const [state, setState] = useState<State>(State.Loading); const onLoginRef = useRef(onLogin); onLoginRef.current = onLogin; const getLoginUser = useCallback(async () => { setState(State.Loading); const openCreationForm = useCallback(() => { viewTransition(() => { setIsOpeningCreationForm(true); }); }, []); try { const resp = await client.getLoginUser({}); const openList = useCallback(() => { viewTransition(() => { setIsOpeningCreationForm(false); }); switch (resp.result.case) { case "ok": onLoginRef.current?.(resp.result.value); return; case "noUserInWorkspace": setState(State.NoAdminInWorkspace); return; case "authenticationError": setState(State.RequireLogin); return; case "systemError": throw resp.result.value; default: throw new IllegalMessageError(resp); } } catch (err) { console.error(err); toast.open({ severity: "danger", title: "ログインユーザ情報が取得できませんでした", dismissible: true, duration: Infinity, type: "foreground", }); setState(State.RequireLogin); } }, [toast.open]); useEffect(() => { getLoginUser(); }, []); if (isOpeningCreationForm) { return ( <Layout title="ワークスペース作成" onBack={openList}> <CreationForm onCreated={openList} /> </Layout> ); switch (state) { case State.Loading: return <Loading />; case State.NoAdminInWorkspace: return ( <InitialAdminCreation onCreated={() => { viewTransition(() => { setState(State.RequireLogin); }); }} onPasswordExpired={() => { viewTransition(() => { setState(State.RequireLogin); }); }} /> ); case State.RequireLogin: return ( <Login onLogin={(user) => { onLogin?.(user); }} /> ); } return ( <Layout title="ワークスペース選択"> <Selector onOpenCreationForm={openCreationForm} /> </Layout> ); }; export const pattern = new URLPattern({
-