feat: complete draft authoring workbench
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user