Changes
34 changed files (+664/-406)
-
-
-
@@ -10,7 +10,6 @@ "clean": "rm -rf dist"}, "dependencies": { "@yamori/idb_backend": "workspace:*", "@yamori/proto": "workspace:*", "@yamori/react_ui": "workspace:*", "react": "^19.0.0", "react-dom": "^19.0.0"
-
-
-
@@ -1,16 +1,13 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { ProtoRPCProvider, type ProtoRPC, ThemeProvider, WorkspaceSelectionPage, WorkspacePagesLayout, WorkerListPage, HistoryAPIRouterProvider, Page, } from "@yamori/react_ui"; import { type FC, useState } from "react"; import { createRoot } from "react-dom/client"; import css from "./main.module.css";
-
@@ -20,24 +17,6 @@ const worker = new Worker(new URL("./worker/main.ts", import.meta.url), {type: "module", }); const App: FC = () => { const [workspace] = useState<Workspace | null>(null); if (!workspace) { return <WorkspaceSelectionPage />; } return ( <WorkspacePagesLayout menu={<></>} title={<WorkerListPage.Title />} actions={<WorkerListPage.Actions workspace={workspace} />} > <WorkerListPage.Page workspace={workspace} /> </WorkspacePagesLayout> ); }; const root = createRoot(document.body); worker.addEventListener("message", (event) => {
-
@@ -72,10 +51,12 @@ },}; root.render( <ProtoRPCProvider rpc={rpc}> <ThemeProvider className={css.theme}> <App /> </ThemeProvider> </ProtoRPCProvider>, <HistoryAPIRouterProvider> <ProtoRPCProvider rpc={rpc}> <ThemeProvider className={css.theme}> <Page /> </ThemeProvider> </ProtoRPCProvider> </HistoryAPIRouterProvider>, ); });
-
-
-
@@ -1,42 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type Decorator } from "@storybook/react"; import { type ReactElement } from "react"; import * as NavigationMenu from "../../src/components/NavigationMenu.ts"; import { WorkspacePagesLayout } from "../../src/components/WorkspacePagesLayout.tsx"; export interface WithWorkspacePagesLayoutOptions { title?: ReactElement; actions?: ReactElement; menu?: ReactElement; } export function withWorkspacePagesLayout<Args>({ title = <>タイトル未設定</>, actions, menu = ( <NavigationMenu.Root> <NavigationMenu.Group title="グループ"> <NavigationMenu.Item> <button>アイテム1</button> </NavigationMenu.Item> <NavigationMenu.Item current> <button>アイテム2</button> </NavigationMenu.Item> <NavigationMenu.Item> <button>アイテム3</button> </NavigationMenu.Item> </NavigationMenu.Group> </NavigationMenu.Root> ), }: WithWorkspacePagesLayoutOptions = {}): Decorator<Args> { return (Story) => { return ( <WorkspacePagesLayout title={title} actions={actions} menu={menu}> <Story /> </WorkspacePagesLayout> ); }; }
-
-
-
@@ -29,7 +29,8 @@ "@radix-ui/themes": "^3.1.6","@tanstack/react-query": "^5.62.8", "@yamori/idb_backend": "workspace:*", "@yamori/proto": "workspace:*", "react-hook-form": "^7.54.2" "react-hook-form": "^7.54.2", "urlpattern-polyfill": "^10.0.0" }, "devDependencies": { "@storybook/addon-essentials": "^8.4.7",
-
-
-
@@ -1,78 +0,0 @@// 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 { withWorkspacePagesLayout } from "../../.storybook/decorators/withWorkspacePagesLayout.tsx"; import { List } from "../mocks/yamori/worker/v1/worker_service.ts"; import { WorkerListPage } from "./WorkerListPage.ts"; const workspace = create(WorkspaceSchema, { id: { value: "ws-foo" }, }); export default { component: WorkerListPage.Page, parameters: { layout: "fullscreen", }, args: { workspace, }, } satisfies Meta<(typeof WorkerListPage)["Page"]>; type Story = StoryObj<(typeof WorkerListPage)["Page"]>; export const Success: Story = { decorators: [ withWorkspacePagesLayout({ title: <WorkerListPage.Title />, actions: <WorkerListPage.Actions workspace={workspace} />, }), withMockedBackend([List({ workspace })]), ], }; export const SlowLoad: Story = { decorators: [ withWorkspacePagesLayout({ title: <WorkerListPage.Title />, actions: <WorkerListPage.Actions workspace={workspace} />, }), withMockedBackend([List({ workspace, delayMs: 2_000 })]), ], }; export const NoWorkers: Story = { decorators: [ withWorkspacePagesLayout({ title: <WorkerListPage.Title />, actions: <WorkerListPage.Actions workspace={workspace} />, }), withMockedBackend([List({ workspace, workers: [] })]), ], }; export const ListLoadError: Story = { decorators: [ withWorkspacePagesLayout({ title: <WorkerListPage.Title />, actions: <WorkerListPage.Actions workspace={workspace} />, }), withMockedBackend([List({ workspace, failureRate: 1 })]), ], }; export const Flaky: Story = { decorators: [ withWorkspacePagesLayout({ title: <WorkerListPage.Title />, actions: <WorkerListPage.Actions workspace={workspace} />, }), withMockedBackend([List({ workspace, failureRate: 0.5 })]), ], };
-
-
-
@@ -1,4 +1,4 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only export * as WorkerListPage from "./WorkerListPage/Page.tsx"; import "urlpattern-polyfill";
-
-
packages/react_ui/src/components/WorkerListPage/List.tsx > packages/react_ui/src/pages/:workspace/workers/List.tsx
-
-
@@ -1,104 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Button, Flex, Heading, Text } from "@radix-ui/themes"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { ListRequestSchema } from "@yamori/proto/yamori/worker/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/worker/v1/list_response_pb.js"; import { type FC, type ReactElement } from "react"; import { useMethodQuery } from "../../contexts/Service.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import { ManagedErrorCallout } from "../ErrorCallout.ts"; import { List } from "./List.tsx"; function useWorkerList(workspace: Workspace) { return useMethodQuery({ service: "yamori.worker.v1.WorkerService", method: "List", request: { schema: ListRequestSchema, data: { workspaceId: workspace.id!, }, }, response: { schema: ListResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, }); } export interface PageProps { workspace: Workspace; /** * @deprecated */ createButton?: ReactElement; } export const Page: FC<PageProps> = ({ workspace }) => { const list = useWorkerList(workspace); if (list.fetchStatus === "idle" && list.data?.workers.length === 0) { return ( <Flex mt="7" direction="column" align="center" gap="3"> <Heading as="h2" size="4"> 労働者が登録されていません </Heading> <Text as="p" size="2" align="center"> ワークスペース内に労働者がまだ一人も登録されていません。 </Text> <Button asChild mt="4" size="3"> <a href={`/${workspace.id?.value}/workers?new`}>登録する</a> </Button> </Flex> ); } return ( <Flex direction="column" mt="5" gap="5"> {!!list.error && ( <ManagedErrorCallout severity={list.data ? "warning" : "danger"} title={list.data ? "一覧の更新に失敗しました" : "一覧の読込に失敗しました"} error={list.error} actions={ <Button size="1" loading={list.isFetching} onClick={() => void list.refetch()} > {list.data ? "再試行" : "再取得"} </Button> } /> )} {!list.data ? <List loading /> : <List workers={list.data.workers} />} </Flex> ); }; export const Title: FC = () => "労働者一覧"; export const Actions: FC<Pick<PageProps, "workspace">> = ({ workspace }) => { const list = useWorkerList(workspace); return ( <Button variant="soft" loading={list.isFetching} onClick={() => void list.refetch()}> 更新 </Button> ); };
-
-
packages/react_ui/src/components/WorkerListPage/Row.tsx > packages/react_ui/src/pages/:workspace/workers/Row.tsx
-
packages/react_ui/src/components/WorkspacePagesLayout.module.css > packages/react_ui/src/pages/:workspace/Layout.module.css
-
-
@@ -1,66 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Pencil2Icon, TrashIcon } from "@radix-ui/react-icons"; import { Button, IconButton } from "@radix-ui/themes"; import type { Meta, StoryObj } from "@storybook/react"; import * as NavigationMenu from "./NavigationMenu.ts"; import { WorkspacePagesLayout } from "./WorkspacePagesLayout.tsx"; export default { component: WorkspacePagesLayout, args: { title: "TITLE", menu: ( <NavigationMenu.Root> <NavigationMenu.Group title="グループ"> <NavigationMenu.Item current> <button>アイテム1</button> </NavigationMenu.Item> <NavigationMenu.Item> <button>アイテム2</button> </NavigationMenu.Item> <NavigationMenu.Item> <button>アイテム3</button> </NavigationMenu.Item> </NavigationMenu.Group> </NavigationMenu.Root> ), children: "CHILDREN", }, parameters: { layout: "fullscreen", }, } satisfies Meta<typeof WorkspacePagesLayout>; type Story = StoryObj<typeof WorkspacePagesLayout>; export const Defaults: Story = {}; export const MenuOpened: Story = { args: { defaultMenuOpened: true, }, }; export const WithTextAction: Story = { args: { actions: <Button>保存</Button>, }, }; export const WithIconActions: Story = { args: { actions: ( <> <IconButton variant="surface"> <TrashIcon /> </IconButton> <IconButton variant="surface"> <Pencil2Icon /> </IconButton> </> ), }, };
-
-
packages/react_ui/src/components/WorkspacePagesLayout.tsx > packages/react_ui/src/pages/:workspace/Layout.tsx
-
@@ -11,15 +11,18 @@ Grid,Heading, IconButton, } from "@radix-ui/themes"; import { type FC, type ReactNode, useCallback, useState } from "react"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type FC, type ReactNode, use, useCallback, useState } from "react"; import { useViewTransition } from "../hooks/useViewTransition.ts"; import * as NavigationMenu from "../../components/NavigationMenu.ts"; import { URLContext } from "../../contexts/Router.tsx"; import { useViewTransition } from "../../hooks/useViewTransition.ts"; import css from "./WorkspacePagesLayout.module.css"; import * as workers from "./workers/page.tsx"; export interface WorkspacePagesLayoutProps extends Pick<BoxProps, "className" | "children" | "style"> { menu: ReactNode; import css from "./Layout.module.css"; export interface LayoutProps extends Pick<BoxProps, "className" | "children" | "style"> { title: ReactNode; /**
-
@@ -29,17 +32,21 @@ */actions?: ReactNode; defaultMenuOpened?: boolean; workspace: Workspace; } export const WorkspacePagesLayout: FC<WorkspacePagesLayoutProps> = ({ menu, export const Layout: FC<LayoutProps> = ({ children, title, actions, defaultMenuOpened = false, workspace, ...rest }) => { const viewTransition = useViewTransition(); const url = use(URLContext); const [isMenuOpened, setIsMenuOpened] = useState(defaultMenuOpened);
-
@@ -85,7 +92,13 @@ gridRowStart="2"gridColumnStart="1" display={{ initial: isMenuOpened ? "block" : "none", md: "block" }} > {menu} <NavigationMenu.Root> <NavigationMenu.Group title="労働者管理"> <NavigationMenu.Item current={workers.pattern.test(url)}> <a href={`/${workspace.id?.value}/workers`}>労働者一覧</a> </NavigationMenu.Item> </NavigationMenu.Group> </NavigationMenu.Root> </Box> <Container size={{ initial: "2", lg: "3" }}
-
-
packages/react_ui/src/components/WorkspaceSelectionPage.stories.tsx > packages/react_ui/src/pages/root/page.stories.tsx
-
@@ -3,19 +3,19 @@ // SPDX-License-Identifier: AGPL-3.0-onlyimport type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../.storybook/decorators/withMockedBackend.tsx"; import { Create, List } from "../mocks/yamori/workspace/v1/workspace_service.ts"; import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { Create, List } from "../../mocks/yamori/workspace/v1/workspace_service.ts"; import { WorkspaceSelectionPage } from "./WorkspaceSelectionPage.tsx"; import { Page } from "./page.tsx"; export default { component: WorkspaceSelectionPage, component: Page, parameters: { layout: "fullscreen", }, } satisfies Meta<typeof WorkspaceSelectionPage>; } satisfies Meta<typeof Page>; type Story = StoryObj<typeof WorkspaceSelectionPage>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([List(), Create()])],
-
-
-
@@ -1,50 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type FC, useCallback, useState } from "react"; import { useViewTransition } from "../hooks/useViewTransition.ts"; import { Layout } from "./WorkspaceSelectionPage/Layout.tsx"; import { Selector } from "./WorkspaceSelectionPage/Selector.tsx"; import { CreationForm } from "./WorkspaceSelectionPage/CreationForm.tsx"; export interface WorkspaceSelectionPageProps { /** * @deprecated */ onOpenWorkspace?(workspace: Workspace): void; } export const WorkspaceSelectionPage: FC<WorkspaceSelectionPageProps> = () => { const viewTransition = useViewTransition(); const [isOpeningCreationForm, setIsOpeningCreationForm] = useState(false); const openCreationForm = useCallback(() => { viewTransition(() => { setIsOpeningCreationForm(true); }); }, []); const openList = useCallback(() => { viewTransition(() => { setIsOpeningCreationForm(false); }); }, []); if (isOpeningCreationForm) { return ( <Layout title="ワークスペース作成" onBack={openList}> <CreationForm onCreated={openList} /> </Layout> ); } return ( <Layout title="ワークスペース選択"> <Selector onOpenCreationForm={openCreationForm} /> </Layout> ); };
-
-
packages/react_ui/src/components/WorkspaceSelectionPage/CreationForm.stories.tsx > packages/react_ui/src/pages/root/CreationForm.stories.tsx
-
packages/react_ui/src/components/WorkspaceSelectionPage/CreationForm.tsx > packages/react_ui/src/pages/root/CreationForm.tsx
-
@@ -7,8 +7,8 @@ import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js";import { type FC } from "react"; import { useForm } from "react-hook-form"; import { ManagedErrorCallout } from "../ErrorCallout.ts"; import * as FormField from "../FormField.ts"; import { ManagedErrorCallout } from "../../components/ErrorCallout.ts"; import * as FormField from "../../components/FormField.ts"; import { useMethodMutation } from "../../contexts/Service.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts";
-
-
packages/react_ui/src/components/WorkspaceSelectionPage/Layout.module.css > packages/react_ui/src/pages/root/Layout.module.css
-
packages/react_ui/src/components/WorkspaceSelectionPage/Layout.tsx > packages/react_ui/src/pages/root/Layout.tsx
-
packages/react_ui/src/components/WorkspaceSelectionPage/Selector.tsx > packages/react_ui/src/pages/root/Selector.tsx
-
@@ -11,10 +11,9 @@ type ListResponse_Result,} from "@yamori/proto/yamori/workspace/v1/list_response_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 { ManagedErrorCallout } from "../ErrorCallout.ts"; import { WorkspaceList } from "./WorkspaceList.tsx";
-
-
packages/react_ui/src/components/WorkspaceSelectionPage/WorkspaceList.stories.tsx > packages/react_ui/src/pages/root/WorkspaceList.stories.tsx
-
packages/react_ui/src/components/WorkspaceSelectionPage/WorkspaceList.tsx > packages/react_ui/src/pages/root/WorkspaceList.tsx
-
-
@@ -1,23 +1,20 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../polyfill.ts"; import { createContext, type FC, type ReactNode, use, useEffect, useMemo, useRef, useState, } from "react"; export interface Route { url: Readonly<URL>; } export const RouteContext = createContext<Route>({ url: new URL(location.href), }); export const URLContext = createContext<URL>(new URL(location.href)); export interface Navigation { push(url: string | URL): void;
-
@@ -40,6 +37,93 @@ };export const NavigationContext = createContext<Navigation>(nativeNavigation); export interface HistoryAPIRouterProviderProps { children: ReactNode; baseURL?: string | URL | undefined; } export const HistoryAPIRouterProvider: FC<HistoryAPIRouterProviderProps> = ({ baseURL, children, }) => { const [url, setURL] = useState(() => new URL(location.href)); const navigation = useMemo<Navigation>(() => { return { push(url) { history.pushState({}, "", url); setURL(new URL(url, location.href)); }, back() { history.back(); setURL(new URL(location.href)); }, forward() { history.forward(); setURL(new URL(location.href)); }, }; }, []); useEffect(() => { const listener = (_event: PopStateEvent) => { setURL(new URL(location.href)); }; window.addEventListener("popstate", listener); return () => void window.removeEventListener("popstate", listener); }, []); useEffect(() => { const root = new URL(baseURL ?? "/", url); const listener = (event: MouseEvent) => { if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { return; } if (!(event.target instanceof Element)) { return; } const anchor = event.target instanceof HTMLAnchorElement ? event.target : event.target.closest("a"); if ( !anchor || !anchor.href || (anchor.hasAttribute("target") && anchor.getAttribute("target") !== "_self") ) { return; } const href = new URL(anchor.href, url); if (!href.toString().startsWith(root.toString())) { return; } event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); navigation.push(href); }; document.addEventListener("click", listener); return () => void document.removeEventListener("click", listener); }, [baseURL, url, navigation]); return ( <NavigationContext.Provider value={navigation}> <URLContext.Provider value={url}>{children}</URLContext.Provider> </NavigationContext.Provider> ); }; export interface InmemoryRouterProviderProps { initialURL?: string | URL | undefined;
-
@@ -150,6 +234,7 @@ }event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); navigation.push(href); };
-
@@ -159,13 +244,68 @@return () => void document.removeEventListener("click", listener); }, [root, history.current, navigation]); const route = useMemo<Route>(() => { return { url: history.current }; }, [history.current]); return ( <NavigationContext.Provider value={navigation}> <RouteContext.Provider value={route}>{children}</RouteContext.Provider> <URLContext.Provider value={history.current}>{children}</URLContext.Provider> </NavigationContext.Provider> ); }; const URLPatternResultContext = createContext<URLPatternResult | null>(null); export function useURLPatternResult(): URLPatternResult { const result = use(URLPatternResultContext); if (!result) { throw new Error("Router invariant: useURLPatternResult was called outside <On/>."); } return result; } export interface SelectProps { routes: readonly { children: ReactNode; pattern: URLPattern; }[]; fallback?: ReactNode; } export const Select: FC<SelectProps> = ({ routes, fallback }) => { const url = use(URLContext); for (const route of routes) { const result = route.pattern.exec(url); if (!result) { continue; } return ( <URLPatternResultContext.Provider value={result}> {route.children} </URLPatternResultContext.Provider> ); } return fallback; }; export interface OnProps { children: ReactNode; pattern: URLPattern; } export const On: FC<OnProps> = ({ children, pattern }) => { const url = use(URLContext); const result = useMemo(() => pattern.exec(url), [pattern, url]); if (!result) { return null; } return ( <URLPatternResultContext.Provider value={result}> {children} </URLPatternResultContext.Provider> ); };
-
-
-
@@ -4,8 +4,6 @@import "@radix-ui/themes/styles.css"; export * from "./components/ThemeProvider.tsx"; export * from "./components/WorkspaceSelectionPage.tsx"; export * from "./components/WorkerListPage.ts"; export * from "./components/WorkspacePagesLayout.tsx"; export * from "./contexts/Service.tsx"; export * from "./contexts/Router.tsx"; export * from "./pages/page.tsx";
-
-
-
@@ -0,0 +1,58 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { Pencil2Icon, TrashIcon } from "@radix-ui/react-icons"; import { Button, IconButton } from "@radix-ui/themes"; import type { Meta, StoryObj } from "@storybook/react"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { Layout } from "./Layout.tsx"; export default { component: Layout, args: { title: "TITLE", children: "CHILDREN", workspace: create(WorkspaceSchema, { id: { value: "ws-foo", }, displayName: "Foo", }), }, parameters: { layout: "fullscreen", }, } satisfies Meta<typeof Layout>; type Story = StoryObj<typeof Layout>; export const Defaults: Story = {}; export const MenuOpened: Story = { args: { defaultMenuOpened: true, }, }; export const WithTextAction: Story = { args: { actions: <Button>保存</Button>, }, }; export const WithIconActions: Story = { args: { actions: ( <> <IconButton variant="surface"> <TrashIcon /> </IconButton> <IconButton variant="surface"> <Pencil2Icon /> </IconButton> </> ), }, };
-
-
-
@@ -0,0 +1,46 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Decorator, Meta, StoryObj } from "@storybook/react"; import { withInmemoryRouter } from "../../../.storybook/decorators/withInmemoryRouter.tsx"; import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { On } from "../../contexts/Router.tsx"; import * as workspaceService from "../../mocks/yamori/workspace/v1/workspace_service.ts"; import { Page, pattern } from "./page.tsx"; function withRoute<Args>(): Decorator<Args> { return (Story) => ( <On pattern={pattern}> <Story /> </On> ); } export default { component: Page, parameters: { layout: "fullscreen", }, decorators: [withMockedBackend([workspaceService.List()])], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const NotFound: Story = { decorators: [withRoute(), withInmemoryRouter({ initialURL: "/ws-foo/invalid" })], }; export const Loading: Story = { decorators: [ withRoute(), withInmemoryRouter({ initialURL: "/ws-foo/invalid" }), withMockedBackend([workspaceService.List({ delayMs: 5_000 })]), ], }; export const NonexistentWorkspace: Story = { decorators: [withRoute(), withInmemoryRouter({ initialURL: "/ws-000/invalid" })], };
-
-
-
@@ -0,0 +1,73 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../polyfill.ts"; import { ListRequestSchema } from "@yamori/proto/yamori/workspace/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js"; import { type FC } from "react"; import { Select, useURLPatternResult } from "../../contexts/Router.tsx"; import { useMethodQuery } from "../../contexts/Service.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import * as workers from "./workers/page.tsx"; import { Layout } from "./Layout.tsx"; export const Page: FC = () => { const { pathname } = useURLPatternResult(); // TODO: 単体取得に切り替える const list = useMethodQuery({ service: "yamori.workspace.v1.WorkspaceService", method: "List", request: { schema: ListRequestSchema, data: {}, }, response: { schema: ListResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, }); if (!list.data) { return "読込中"; } const workspace = list.data.workspaces.find( (workspace) => workspace.id?.value === pathname.groups.workspace!, ); if (!workspace) { return "合致するワークスペースなし"; } return ( <Select routes={[ { pattern: workers.pattern, children: <workers.Page workspace={workspace} />, }, ]} fallback={ <Layout title="404" workspace={workspace}> Not found </Layout> } /> ); }; export const pattern = new URLPattern({ pathname: "/:workspace(ws-[^/]+)/:frag*", });
-
-
-
@@ -0,0 +1,49 @@// 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 { List } from "../../../mocks/yamori/worker/v1/worker_service.ts"; import { Page } from "./page.tsx"; const workspace = create(WorkspaceSchema, { id: { value: "ws-foo" }, }); export default { component: Page, parameters: { layout: "fullscreen", }, args: { workspace, }, decorators: [withInmemoryRouter({ initialURL: "/ws-foo/workers" })], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([List({ workspace })])], }; export const SlowLoad: Story = { decorators: [withMockedBackend([List({ workspace, delayMs: 2_000 })])], }; export const NoWorkers: Story = { decorators: [withMockedBackend([List({ workspace, workers: [] })])], }; export const ListLoadError: Story = { decorators: [withMockedBackend([List({ workspace, failureRate: 1 })])], }; export const Flaky: Story = { decorators: [withMockedBackend([List({ workspace, failureRate: 0.5 })])], };
-
-
-
@@ -0,0 +1,120 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../polyfill.ts"; import { Button, Flex, Heading, Text } from "@radix-ui/themes"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { ListRequestSchema } from "@yamori/proto/yamori/worker/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/worker/v1/list_response_pb.js"; import { type FC } from "react"; import { ManagedErrorCallout } from "../../../components/ErrorCallout.ts"; import { useMethodQuery } from "../../../contexts/Service.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { Layout } from "../Layout.tsx"; import { List } from "./List.tsx"; function useWorkerList(workspace: Workspace) { return useMethodQuery({ service: "yamori.worker.v1.WorkerService", method: "List", request: { schema: ListRequestSchema, data: { workspaceId: workspace.id!, }, }, response: { schema: ListResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, }); } interface BodyProps { workspace: Workspace; workers: ReturnType<typeof useWorkerList>; } const Body: FC<BodyProps> = ({ workers, workspace }) => { if (workers.fetchStatus === "idle" && workers.data?.workers.length === 0) { return ( <Flex mt="7" direction="column" align="center" gap="3"> <Heading as="h2" size="4"> 労働者が登録されていません </Heading> <Text as="p" size="2" align="center"> ワークスペース内に労働者がまだ一人も登録されていません。 </Text> <Button asChild mt="4" size="3"> <a href={`/${workspace.id?.value}/workers/new`}>登録する</a> </Button> </Flex> ); } return ( <Flex direction="column" mt="5" gap="5"> {!!workers.error && ( <ManagedErrorCallout severity={workers.data ? "warning" : "danger"} title={workers.data ? "一覧の更新に失敗しました" : "一覧の読込に失敗しました"} error={workers.error} actions={ <Button size="1" loading={workers.isFetching} onClick={() => void workers.refetch()} > {workers.data ? "再試行" : "再取得"} </Button> } /> )} {!workers.data ? <List loading /> : <List workers={workers.data.workers} />} </Flex> ); }; export interface PageProps { workspace: Workspace; } export const Page: FC<PageProps> = ({ workspace }) => { const list = useWorkerList(workspace); return ( <Layout workspace={workspace} title="労働者一覧" actions={ <Button variant="soft" loading={list.isFetching} onClick={() => void list.refetch()} > 更新 </Button> } > <Body workspace={workspace} workers={list} /> </Layout> ); }; export const pattern = new URLPattern({ pathname: "/:workspace/workers", });
-
-
-
@@ -0,0 +1,41 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { withInmemoryRouter } from "../../.storybook/decorators/withInmemoryRouter.tsx"; import { withMockedBackend } from "../../.storybook/decorators/withMockedBackend.tsx"; import { withIDBBackend } from "../../.storybook/decorators/withIDBBackend.tsx"; import { Page } from "./page.tsx"; import * as workspaceService from "../mocks/yamori/workspace/v1/workspace_service.ts"; import * as workerService from "../mocks/yamori/worker/v1/worker_service.ts"; export default { component: Page, parameters: { layout: "fullscreen", }, decorators: [ withMockedBackend([ workspaceService.List(), workspaceService.Create(), workerService.List(), ]), ], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Playground: Story = { decorators: [withIDBBackend(), withInmemoryRouter({ initialURL: "/" })], }; export const Root: Story = { decorators: [withInmemoryRouter({ initialURL: "/" })], }; export const NotFound: Story = { decorators: [withInmemoryRouter({ initialURL: "/invalid" })], };
-
-
-
@@ -0,0 +1,29 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../polyfill.ts"; import { type FC } from "react"; import { Select } from "../contexts/Router.tsx"; import * as root from "./root/page.tsx"; import * as workspace from "./:workspace/page.tsx"; export const Page: FC = () => { return ( <Select routes={[ { pattern: root.pattern, children: <root.Page />, }, { pattern: workspace.pattern, children: <workspace.Page />, }, ]} fallback="Not Found" /> ); };
-
-
-
@@ -0,0 +1,48 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../polyfill.ts"; import { type FC, useCallback, useState } from "react"; import { useViewTransition } from "../../hooks/useViewTransition.ts"; import { Layout } from "./Layout.tsx"; import { Selector } from "./Selector.tsx"; import { CreationForm } from "./CreationForm.tsx"; export const Page: FC = () => { const viewTransition = useViewTransition(); const [isOpeningCreationForm, setIsOpeningCreationForm] = useState(false); const openCreationForm = useCallback(() => { viewTransition(() => { setIsOpeningCreationForm(true); }); }, []); const openList = useCallback(() => { viewTransition(() => { setIsOpeningCreationForm(false); }); }, []); if (isOpeningCreationForm) { return ( <Layout title="ワークスペース作成" onBack={openList}> <CreationForm onCreated={openList} /> </Layout> ); } return ( <Layout title="ワークスペース選択"> <Selector onOpenCreationForm={openCreationForm} /> </Layout> ); }; export const pattern = new URLPattern({ pathname: "/", });
-
-
-
@@ -2,6 +2,8 @@ {"extends": "../../tsconfig.jsonc", "compilerOptions": { "moduleResolution": "Bundler", "moduleDetection": "force", "exactOptionalPropertyTypes": false, "noEmit": true, "lib": ["ES2020", "DOM", "DOM.iterable"], "types": ["vite/client"],
-
-
-
@@ -25,6 +25,7 @@ "react-hook-form",/^@tanstack\//, /^@yamori\//, /^@bufbuild\//, "urlpattern-polyfill", ], }, },
-