feat: hand capabilities into draft authoring

This commit is contained in:
lda
2026-08-09 08:47:03 +07:00 Verified
parent ef773dedb3
commit ee0b77125d
9 changed files with 718 additions and 19 deletions
@@ -0,0 +1,188 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { initialState } from "../../app/state.js";
import { useConsoleWorkspace } from "../context.js";
import {
createDraftAuthoringClient,
type DraftAuthoringClient,
} from "../domain/draft-authoring-client.js";
import type { CapabilityDetail } from "../domain/capability-models.js";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import { CreateDraftDialog } from "./CreateDraftDialog.js";
import type { DraftWorkspaceController } from "../routes/useDraftWorkspace.js";
import { useDraftWorkspace } from "../routes/useDraftWorkspace.js";
vi.mock("../context.js", () => ({
useConsoleWorkspace: vi.fn(),
}));
vi.mock("../domain/draft-authoring-client.js", () => ({
createDraftAuthoringClient: vi.fn(),
}));
vi.mock("../routes/useDraftWorkspace.js", () => ({
useDraftWorkspace: vi.fn(),
}));
const mockedUseConsoleWorkspace = vi.mocked(useConsoleWorkspace);
const mockedCreateDraftAuthoringClient = vi.mocked(createDraftAuthoringClient);
const mockedUseDraftWorkspace = vi.mocked(useDraftWorkspace);
const capability: CapabilityDetail = {
kind: "node_spec",
name: "local.documents.read",
sourceId: "local.documents",
description: "Read documents.",
isAsync: false,
outcomes: ["ok", "error"],
inputSchema: { type: "object" },
outputSchema: { type: "object" },
wrapperHints: {},
acceptsContext: true,
};
const workspace = (workspaceId: string): DraftWorkspace => ({
workspaceId,
revision: 1,
title: "Existing draft",
status: "invalid",
diagnostics: [],
summary: { name: "existing", start: null, stepCount: 0, routeCount: 0, steps: [] },
draft: null,
});
const controller: DraftWorkspaceController = {
listPhase: "ready",
detailPhase: "idle",
items: [workspace("draft-existing")],
selected: null,
listMessage: null,
detailMessage: null,
refresh: vi.fn(),
};
const authoringClient: DraftAuthoringClient = {
createEmpty: vi.fn(),
createFromCapability: vi.fn(),
addCapabilityStep: vi.fn(),
updateCapabilityStep: vi.fn(),
setRoute: vi.fn(),
validate: vi.fn(),
};
const DraftDestination = () => {
const location = useLocation();
return <p>Destination: {location.pathname}{location.search}</p>;
};
const renderDialog = (selectedCapability: CapabilityDetail | null = capability) =>
render(
<MemoryRouter initialEntries={["/console/discover"]}>
<Routes>
<Route
path="/console/discover"
element={<CreateDraftDialog capability={selectedCapability} onClose={vi.fn()} />}
/>
<Route path="/console/drafts/:workspaceId" element={<DraftDestination />} />
</Routes>
</MemoryRouter>,
);
beforeEach(() => {
mockedUseConsoleWorkspace.mockReturnValue({
connection: initialState(),
connectedTarget: "http://workflow.test/rpc",
recordEvidence: vi.fn(),
readExecutor: null,
writeExecutor: { run: vi.fn() },
});
mockedCreateDraftAuthoringClient.mockReturnValue(authoringClient);
mockedUseDraftWorkspace.mockReturnValue(controller);
});
afterEach(() => cleanup());
describe("CreateDraftDialog", () => {
it("uses a native modal lifecycle with focus and cancel handling", async () => {
const user = userEvent.setup();
const DialogHarness = () => {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)} type="button">
Open dialog
</button>
{open && <CreateDraftDialog capability={null} onClose={() => setOpen(false)} />}
</>
);
};
render(
<MemoryRouter initialEntries={["/console/discover"]}>
<DialogHarness />
</MemoryRouter>,
);
const opener = screen.getByRole("button", { name: "Open dialog" });
await user.click(opener);
const dialog = screen.getByRole("dialog");
expect(dialog.tagName).toBe("DIALOG");
expect(dialog).toHaveAttribute("open");
expect(screen.getByRole("textbox", { name: "Workspace id" })).toHaveFocus();
await user.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(opener).toHaveFocus();
await user.click(opener);
const reopenedDialog = screen.getByRole("dialog");
fireEvent(reopenedDialog, new Event("cancel", { bubbles: true, cancelable: true }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(opener).toHaveFocus();
});
it("offers an existing draft and a seeded capability draft", async () => {
const user = userEvent.setup();
renderDialog();
expect(screen.getByRole("dialog", { name: "Add capability to draft" })).toBeInTheDocument();
await user.selectOptions(screen.getByRole("combobox", { name: "Existing draft" }), "draft-existing");
await user.click(screen.getByRole("button", { name: "Use existing draft" }));
expect(await screen.findByText("Destination: /console/drafts/draft-existing?capability=local.documents.read")).toBeInTheDocument();
});
it("uses the canonical workspace id returned by seeded creation", async () => {
const user = userEvent.setup();
vi.mocked(authoringClient.createFromCapability).mockResolvedValue(workspace("canonical-created"));
renderDialog();
await user.type(screen.getByRole("textbox", { name: "Workspace id" }), "requested-id");
await user.type(screen.getByRole("textbox", { name: "Draft name" }), "report-workflow");
await user.click(screen.getByRole("button", { name: "Create seeded draft" }));
expect(await screen.findByText("Destination: /console/drafts/canonical-created?capability=local.documents.read")).toBeInTheDocument();
});
it("creates an empty draft when no capability was handed off", async () => {
const user = userEvent.setup();
vi.mocked(authoringClient.createEmpty).mockResolvedValue(workspace("canonical-empty"));
renderDialog(null);
await user.type(screen.getByRole("textbox", { name: "Workspace id" }), "requested-id");
await user.type(screen.getByRole("textbox", { name: "Draft name" }), "report-workflow");
await user.click(screen.getByRole("button", { name: "Create draft" }));
expect(authoringClient.createEmpty).toHaveBeenCalledWith({
workspaceId: "requested-id",
name: "report-workflow",
title: "",
});
expect(await screen.findByText("Destination: /console/drafts/canonical-empty")).toBeInTheDocument();
});
});
@@ -0,0 +1,245 @@
import {
useEffect,
useMemo,
useRef,
useState,
type SyntheticEvent,
} from "react";
import { useNavigate } from "react-router-dom";
import { useConsoleWorkspace } from "../context.js";
import {
createDraftAuthoringClient,
type DraftAuthoringClient,
} from "../domain/draft-authoring-client.js";
import type { CapabilityDetail } from "../domain/capability-models.js";
import { useDraftWorkspace } from "../routes/useDraftWorkspace.js";
export type CreateDraftDialogProps = {
readonly capability: CapabilityDetail | null;
readonly onClose: () => void;
};
type DialogPhase = "idle" | "saving" | "error";
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
const draftPath = (workspaceId: string, capability: CapabilityDetail | null): string => {
const path = `/console/drafts/${encodeURIComponent(workspaceId)}`;
return capability === null
? path
: `${path}?capability=${encodeURIComponent(capability.name)}`;
};
const showModal = (dialog: HTMLDialogElement): void => {
if (dialog.open) return;
if (typeof dialog.showModal === "function") {
try {
dialog.showModal();
return;
} catch {
// Some test DOMs expose showModal without implementing it.
}
}
dialog.setAttribute("open", "");
};
const closeModal = (dialog: HTMLDialogElement): void => {
if (!dialog.open) return;
if (typeof dialog.close === "function") {
dialog.close();
return;
}
dialog.removeAttribute("open");
};
export const CreateDraftDialog = ({
capability,
onClose,
}: CreateDraftDialogProps) => {
const navigate = useNavigate();
const { writeExecutor } = useConsoleWorkspace();
const drafts = useDraftWorkspace(null);
const dialogRef = useRef<HTMLDialogElement>(null);
const onCloseRef = useRef(onClose);
const unmountingRef = useRef(false);
const client = useMemo<DraftAuthoringClient | null>(
() => (writeExecutor ? createDraftAuthoringClient(writeExecutor) : null),
[writeExecutor],
);
const [workspaceId, setWorkspaceId] = useState("");
const [name, setName] = useState("");
const [title, setTitle] = useState("");
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
const [phase, setPhase] = useState<DialogPhase>("idle");
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
useEffect(() => {
const dialog = dialogRef.current;
if (dialog === null) return;
const previouslyFocused =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
showModal(dialog);
dialog.querySelector<HTMLElement>("[data-dialog-autofocus]")?.focus();
return () => {
unmountingRef.current = true;
closeModal(dialog);
if (previouslyFocused !== null && document.contains(previouslyFocused)) {
previouslyFocused.focus();
}
};
}, []);
const handleCancel = (event: SyntheticEvent<HTMLDialogElement>): void => {
event.preventDefault();
onCloseRef.current();
};
const handleDialogClose = (): void => {
if (!unmountingRef.current) onCloseRef.current();
};
const createDraft = async (): Promise<void> => {
if (client === null) {
setPhase("error");
setMessage("Connect to a workflow server before creating a draft.");
return;
}
setPhase("saving");
setMessage(null);
try {
const created =
capability === null
? await client.createEmpty({ workspaceId, name, title })
: await client.createFromCapability({
workspaceId,
name,
title,
capabilityName: capability.name,
});
navigate(draftPath(created.workspaceId, capability));
} catch (error: unknown) {
setPhase("error");
setMessage(errorMessage(error));
}
};
return (
<dialog
aria-labelledby="create-draft-dialog-heading"
className="draft-create-dialog"
onCancel={handleCancel}
onClose={handleDialogClose}
ref={dialogRef}
>
<div className="draft-create-dialog__header">
<div>
<p className="workspace-route-pending__eyebrow">
{capability === null ? "Draft authoring" : "Capability handoff"}
</p>
<h2 id="create-draft-dialog-heading">
{capability === null ? "Create a draft workspace" : "Add capability to draft"}
</h2>
</div>
<button aria-label="Close" onClick={onClose} type="button">
Close
</button>
</div>
{capability !== null && (
<section aria-labelledby="existing-draft-heading" className="draft-create-dialog__section">
<h3 id="existing-draft-heading">Use an existing draft</h3>
{drafts.listPhase === "loading" && <p role="status">Loading draft workspaces...</p>}
{drafts.listPhase === "error" && (
<p role="alert">{drafts.listMessage ?? "Draft workspace list failed."}</p>
)}
{drafts.listPhase === "ready" && drafts.items.length === 0 && (
<p role="status">No existing draft workspaces are available.</p>
)}
{drafts.items.length > 0 && (
<>
<label htmlFor="existing-draft">Existing draft</label>
<select
id="existing-draft"
onChange={(event) => setSelectedWorkspaceId(event.target.value)}
value={selectedWorkspaceId}
>
<option value="">Choose a draft workspace</option>
{drafts.items.map((draft) => (
<option key={draft.workspaceId} value={draft.workspaceId}>
{draft.title?.trim() || draft.workspaceId} ({draft.workspaceId})
</option>
))}
</select>
<button
disabled={!selectedWorkspaceId || phase === "saving"}
onClick={() => {
if (selectedWorkspaceId) {
navigate(draftPath(selectedWorkspaceId, capability));
}
}}
type="button"
>
Use existing draft
</button>
</>
)}
</section>
)}
<form
className="draft-create-dialog__section"
onSubmit={(event) => {
event.preventDefault();
void createDraft();
}}
>
<h3>{capability === null ? "New draft" : "Create seeded draft"}</h3>
<div>
<label htmlFor="draft-workspace-id">Workspace id</label>
<input
id="draft-workspace-id"
onChange={(event) => setWorkspaceId(event.target.value)}
required
type="text"
value={workspaceId}
data-dialog-autofocus="true"
/>
</div>
<div>
<label htmlFor="draft-name">Draft name</label>
<input
id="draft-name"
onChange={(event) => setName(event.target.value)}
required
type="text"
value={name}
/>
</div>
<div>
<label htmlFor="draft-title">Title</label>
<input
id="draft-title"
onChange={(event) => setTitle(event.target.value)}
type="text"
value={title}
/>
</div>
{phase === "error" && message !== null && <p role="alert">{message}</p>}
<button disabled={phase === "saving"} type="submit">
{phase === "saving"
? "Creating draft..."
: capability === null
? "Create draft"
: "Create seeded draft"}
</button>
</form>
</dialog>
);
};