feat: complete draft authoring workbench

This commit is contained in:
lda
2026-08-09 10:09:08 +07:00 Verified
parent 1dd56239ad
commit abe83a0f24
7 changed files with 571 additions and 45 deletions
@@ -1,8 +1,18 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CapabilityDetail } from "../domain/capability-models.js";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import { DraftWorkbench } from "./DraftWorkbench.js";
vi.mock("./useAuthoringCapabilityDetail.js", () => ({
useAuthoringCapabilityDetail: vi.fn(),
}));
import { useAuthoringCapabilityDetail } from "./useAuthoringCapabilityDetail.js";
const mockedUseAuthoringCapabilityDetail = vi.mocked(useAuthoringCapabilityDetail);
const workspace: DraftWorkspace = {
workspaceId: "draft-review",
revision: 2,
@@ -33,7 +43,35 @@ const workspace: DraftWorkspace = {
},
};
const capabilityDetail: CapabilityDetail = {
kind: "node_spec",
name: "demo.collect",
sourceId: "demo",
description: "Collect source material.",
isAsync: false,
outcomes: ["ok"],
inputSchema: { type: "object", properties: {} },
outputSchema: { type: "object", properties: {} },
wrapperHints: {},
acceptsContext: false,
};
const setViewport = (width: number): void => {
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: width,
});
};
afterEach(() => cleanup());
beforeEach(() => {
setViewport(1024);
mockedUseAuthoringCapabilityDetail.mockReturnValue({
phase: "disconnected",
detail: null,
message: null,
});
});
describe("DraftWorkbench", () => {
it("keeps palette, graph, and inspector visible in the desktop shell", () => {
@@ -78,4 +116,83 @@ describe("DraftWorkbench", () => {
expect(screen.getByRole("button", { name: label })).toBeDisabled();
}
});
it("uses named mobile sheets, keeps selection persistent, and returns focus on close", async () => {
setViewport(390);
const user = userEvent.setup();
const { container } = render(
<DraftWorkbench
capabilities={[
{
kind: "node_spec",
name: "demo.collect",
sourceId: "demo",
description: "Collect source material.",
outcomes: ["ok"],
inputFields: [],
outputFields: [],
},
]}
draft={workspace}
/>,
);
const paletteTrigger = screen.getByRole("button", { name: "Open capability palette" });
const palette = container.querySelector("#draft-workbench-palette");
expect(palette).not.toBeNull();
expect(palette).toHaveAttribute("aria-label", "Capability palette sheet");
expect(screen.getByRole("region", { name: "Workflow graph" })).toBeInTheDocument();
await user.click(paletteTrigger);
expect(palette).toHaveAttribute("open", "");
await user.click(screen.getByRole("button", { name: "demo.collect" }));
expect(container.querySelector(".draft-workbench")).toHaveAttribute(
"data-selection-kind",
"capability",
);
const inspectorTrigger = screen.getByRole("button", { name: "Open context inspector" });
await user.click(screen.getByRole("button", { name: "Close capability palette" }));
expect(paletteTrigger).toHaveFocus();
await user.click(inspectorTrigger);
const inspector = container.querySelector("#draft-workbench-inspector");
expect(inspector).toHaveAttribute("aria-label", "Context inspector sheet");
expect(inspector).toHaveAttribute("open", "");
expect(within(inspector as HTMLElement).getByText("demo.collect")).toBeInTheDocument();
});
it("keeps a dirty inspector form mounted and intact across mobile close and reopen", async () => {
setViewport(390);
mockedUseAuthoringCapabilityDetail.mockReturnValue({
phase: "ready",
detail: capabilityDetail,
message: null,
});
const user = userEvent.setup();
const { container } = render(
<DraftWorkbench
draft={workspace}
initialSelection={{ kind: "node", nodeId: "collect" }}
/>,
);
const inspector = container.querySelector("#draft-workbench-inspector") as HTMLElement;
const description = within(inspector).getByRole("textbox", {
name: "Description",
hidden: true,
});
await user.type(description, " locally edited");
expect(container.querySelector(".draft-workbench")).toHaveAttribute("data-dirty", "true");
const inspectorTrigger = screen.getByRole("button", { name: "Open context inspector" });
await user.click(inspectorTrigger);
await user.click(screen.getByRole("button", { name: "Close context inspector" }));
expect(inspectorTrigger).toHaveFocus();
await user.click(inspectorTrigger);
expect(
within(inspector).getByRole("textbox", { name: "Description" }),
).toHaveValue(" locally edited");
expect(container.querySelector(".draft-workbench")).toHaveAttribute("data-dirty", "true");
});
});
@@ -1,4 +1,4 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import { useBlocker } from "react-router-dom";
import type { CapabilitySummary } from "../domain/capability-models.js";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
@@ -17,8 +17,106 @@ type DraftWorkbenchProps = {
readonly enableNavigationProtection?: boolean;
};
type MobileSheet = "palette" | "inspector";
const MOBILE_BREAKPOINT = 850;
const EMPTY_CAPABILITIES: ReadonlyArray<CapabilitySummary> = [];
const isMobileViewport = (): boolean =>
typeof window !== "undefined" && window.innerWidth <= MOBILE_BREAKPOINT;
const useMobileViewport = (): boolean => {
const [mobile, setMobile] = useState(isMobileViewport);
useEffect(() => {
const handleResize = (): void => setMobile(isMobileViewport());
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return mobile;
};
type MobileSheetProps = {
readonly id: string;
readonly label: string;
readonly closeLabel: string;
readonly isMobile: boolean;
readonly open: boolean;
readonly triggerRef: RefObject<HTMLButtonElement | null>;
readonly onClose: () => void;
readonly children: ReactNode;
};
const MobileSheet = ({
id,
label,
closeLabel,
isMobile,
open,
triggerRef,
onClose,
children,
}: MobileSheetProps) => {
const dialogRef = useRef<HTMLDialogElement>(null);
const wasOpen = useRef(open);
useEffect(() => {
const dialog = dialogRef.current;
if (dialog === null) return;
const closeDialog = (): void => {
if (typeof dialog.close === "function") {
dialog.close();
} else {
dialog.removeAttribute("open");
}
};
if (!isMobile) {
closeDialog();
dialog.setAttribute("open", "");
return;
}
if (open) {
if (!dialog.open) {
if (typeof dialog.showModal === "function") {
dialog.showModal();
} else {
dialog.setAttribute("open", "");
}
}
} else {
closeDialog();
}
}, [isMobile, open]);
useEffect(() => {
if (wasOpen.current && !open) triggerRef.current?.focus();
wasOpen.current = open;
}, [open, triggerRef]);
return (
<dialog
aria-label={label}
className="draft-workbench__sheet"
id={id}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
ref={dialogRef}
>
<button className="draft-workbench__sheet-close" onClick={onClose} type="button">
{closeLabel}
</button>
{children}
</dialog>
);
};
export const DraftWorkbench = ({
draft,
capabilities = EMPTY_CAPABILITIES,
@@ -27,7 +125,13 @@ export const DraftWorkbench = ({
enableNavigationProtection = false,
}: DraftWorkbenchProps) => {
const controller = useDraftAuthoring({ draft, initialSelection });
const graph = projectAuthoringGraph(draft.draft);
const isMobile = useMobileViewport();
const [openSheet, setOpenSheet] = useState<MobileSheet | null>(null);
const paletteTriggerRef = useRef<HTMLButtonElement>(null);
const inspectorTriggerRef = useRef<HTMLButtonElement>(null);
// Resolve capability details from the controller draft so a newly committed
// node can immediately render its edit form before the route-level loader refreshes.
const graph = projectAuthoringGraph(controller.draft.draft);
let capabilityName: string | null = null;
if (controller.selection.kind === "capability") {
capabilityName = controller.selection.qualifiedName;
@@ -52,26 +156,74 @@ export const DraftWorkbench = ({
return (
<>
{enableNavigationProtection && <DirtyNavigationProtection dirty={controller.dirty} />}
<div className="draft-workbench" data-selection-kind={controller.selection.kind}>
<CapabilityPalette
capabilities={capabilities}
onSelectionChange={select}
selection={controller.selection}
/>
<div
className="draft-workbench"
data-dirty={controller.dirty}
data-selection-kind={controller.selection.kind}
>
<div
aria-label="Mobile authoring panels"
className="draft-workbench__mobile-controls"
role="group"
>
<button
aria-controls="draft-workbench-palette"
aria-expanded={openSheet === "palette"}
onClick={() => setOpenSheet("palette")}
ref={paletteTriggerRef}
type="button"
>
Open capability palette
</button>
<button
aria-controls="draft-workbench-inspector"
aria-expanded={openSheet === "inspector"}
onClick={() => setOpenSheet("inspector")}
ref={inspectorTriggerRef}
type="button"
>
Open context inspector
</button>
</div>
<MobileSheet
closeLabel="Close capability palette"
id="draft-workbench-palette"
isMobile={isMobile}
label="Capability palette sheet"
onClose={() => setOpenSheet(null)}
open={!isMobile || openSheet === "palette"}
triggerRef={paletteTriggerRef}
>
<CapabilityPalette
capabilities={capabilities}
onSelectionChange={select}
selection={controller.selection}
/>
</MobileSheet>
<AuthoringGraph
draft={controller.draft.draft}
onSelectionChange={select}
selection={controller.selection}
/>
<ContextInspector
capabilities={capabilities}
capabilityDetail={capabilityDetail.detail}
capabilityDetailMessage={capabilityDetail.message}
capabilityDetailPhase={capabilityDetail.phase}
controller={controller}
draft={controller.draft}
selection={controller.selection}
/>
<MobileSheet
closeLabel="Close context inspector"
id="draft-workbench-inspector"
isMobile={isMobile}
label="Context inspector sheet"
onClose={() => setOpenSheet(null)}
open={!isMobile || openSheet === "inspector"}
triggerRef={inspectorTriggerRef}
>
<ContextInspector
capabilities={capabilities}
capabilityDetail={capabilityDetail.detail}
capabilityDetailMessage={capabilityDetail.message}
capabilityDetailPhase={capabilityDetail.phase}
controller={controller}
draft={controller.draft}
selection={controller.selection}
/>
</MobileSheet>
</div>
</>
);