feat: hand capabilities into draft authoring
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,40 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DraftWorkspaceController } from "./useDraftWorkspace.js";
|
||||
import { useDraftWorkspace } from "./useDraftWorkspace.js";
|
||||
import type { CapabilityDiscoveryController } from "./useCapabilityDiscovery.js";
|
||||
import { useCapabilityDiscovery } from "./useCapabilityDiscovery.js";
|
||||
import { DiscoverRoute } from "./DiscoverRoute.js";
|
||||
import { useConsoleWorkspace } from "../context.js";
|
||||
import {
|
||||
createDraftAuthoringClient,
|
||||
type DraftAuthoringClient,
|
||||
} from "../domain/draft-authoring-client.js";
|
||||
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import { initialState } from "../../app/state.js";
|
||||
|
||||
vi.mock("./useCapabilityDiscovery.js", () => ({
|
||||
useCapabilityDiscovery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./useDraftWorkspace.js", () => ({
|
||||
useDraftWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context.js", () => ({
|
||||
useConsoleWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../domain/draft-authoring-client.js", () => ({
|
||||
createDraftAuthoringClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseCapabilityDiscovery = vi.mocked(useCapabilityDiscovery);
|
||||
const mockedUseDraftWorkspace = vi.mocked(useDraftWorkspace);
|
||||
const mockedUseConsoleWorkspace = vi.mocked(useConsoleWorkspace);
|
||||
const mockedCreateDraftAuthoringClient = vi.mocked(createDraftAuthoringClient);
|
||||
|
||||
const summary = {
|
||||
kind: "node_spec" as const,
|
||||
@@ -21,6 +46,32 @@ const summary = {
|
||||
outputFields: ["documents"],
|
||||
};
|
||||
|
||||
const draft = (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 draftController = (): DraftWorkspaceController => ({
|
||||
listPhase: "ready",
|
||||
detailPhase: "idle",
|
||||
items: [draft("draft-existing")],
|
||||
selected: null,
|
||||
listMessage: null,
|
||||
detailMessage: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
const controller = (
|
||||
overrides: Partial<CapabilityDiscoveryController> = {},
|
||||
): CapabilityDiscoveryController => ({
|
||||
@@ -39,12 +90,47 @@ const controller = (
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => mockedUseCapabilityDiscovery.mockReturnValue(controller()));
|
||||
const authoringClient: DraftAuthoringClient = {
|
||||
createEmpty: vi.fn(),
|
||||
createFromCapability: vi.fn(),
|
||||
addCapabilityStep: vi.fn(),
|
||||
updateCapabilityStep: vi.fn(),
|
||||
setRoute: vi.fn(),
|
||||
validate: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockedUseCapabilityDiscovery.mockReturnValue(controller());
|
||||
mockedUseDraftWorkspace.mockReturnValue(draftController());
|
||||
mockedUseConsoleWorkspace.mockReturnValue({
|
||||
connection: initialState(),
|
||||
connectedTarget: "http://workflow.test/rpc",
|
||||
recordEvidence: vi.fn(),
|
||||
readExecutor: null,
|
||||
writeExecutor: { run: vi.fn() },
|
||||
});
|
||||
mockedCreateDraftAuthoringClient.mockReturnValue(authoringClient);
|
||||
});
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const renderRoute = () =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/console/discover"]}>
|
||||
<Routes>
|
||||
<Route path="/console/discover" element={<DiscoverRoute />} />
|
||||
<Route path="/console/drafts/:workspaceId" element={<DraftDestination />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const DraftDestination = () => {
|
||||
const location = useLocation();
|
||||
return <p>Draft destination: {location.pathname}{location.search}</p>;
|
||||
};
|
||||
|
||||
describe("DiscoverRoute", () => {
|
||||
it("shows the discovery heading and searchable source-filtered controls", () => {
|
||||
render(<DiscoverRoute />);
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Discover capabilities" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Search capabilities" })).toBeInTheDocument();
|
||||
@@ -55,7 +141,7 @@ describe("DiscoverRoute", () => {
|
||||
it("renders compact capability rows with contract summary fields", async () => {
|
||||
const inspect = vi.fn();
|
||||
mockedUseCapabilityDiscovery.mockReturnValue(controller({ inspect }));
|
||||
render(<DiscoverRoute />);
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByText("Node spec")).toBeInTheDocument();
|
||||
expect(screen.getByText("Source: local.documents")).toBeInTheDocument();
|
||||
@@ -75,7 +161,7 @@ describe("DiscoverRoute", () => {
|
||||
mockedUseCapabilityDiscovery.mockReturnValue(
|
||||
controller({ phase, message: phase === "error" ? message : null, items: [] }),
|
||||
);
|
||||
render(<DiscoverRoute />);
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByText(message)).toBeInTheDocument();
|
||||
});
|
||||
@@ -93,13 +179,13 @@ describe("DiscoverRoute", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(<DiscoverRoute />);
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Input schema" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Output schema" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Wrapper hints" })).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/"names"/)).toHaveLength(2);
|
||||
expect(screen.queryByRole("button", { name: /add to draft/i })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Add to draft" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("exposes selected row state and associates the result with its detail", () => {
|
||||
@@ -115,7 +201,7 @@ describe("DiscoverRoute", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(<DiscoverRoute />);
|
||||
renderRoute();
|
||||
|
||||
const row = screen.getByRole("button", { name: /local\.documents\.read/i });
|
||||
expect(row).toHaveAttribute("aria-pressed", "true");
|
||||
@@ -131,14 +217,14 @@ describe("DiscoverRoute", () => {
|
||||
mockedUseCapabilityDiscovery.mockReturnValue(
|
||||
controller({ nextCursor: "page-2", loadMore }),
|
||||
);
|
||||
render(<DiscoverRoute />);
|
||||
renderRoute();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Load more capabilities" }));
|
||||
expect(loadMore).toHaveBeenCalledOnce();
|
||||
|
||||
cleanup();
|
||||
mockedUseCapabilityDiscovery.mockReturnValue(controller());
|
||||
render(<DiscoverRoute />);
|
||||
renderRoute();
|
||||
expect(screen.queryByRole("button", { name: "Load more capabilities" })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -150,4 +236,65 @@ describe("DiscoverRoute", () => {
|
||||
|
||||
expect(screen.getByRole("button", { name: "Load more capabilities" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("hands an inspected capability to an existing draft through the URL", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedUseCapabilityDiscovery.mockReturnValue(
|
||||
controller({
|
||||
selected: {
|
||||
...summary,
|
||||
isAsync: false,
|
||||
inputSchema: {},
|
||||
outputSchema: {},
|
||||
wrapperHints: {},
|
||||
acceptsContext: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
renderRoute();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add to draft" }));
|
||||
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("Draft destination: /console/drafts/draft-existing?capability=local.documents.read"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates a seeded draft and routes by the canonical workspace id", async () => {
|
||||
const user = userEvent.setup();
|
||||
const created = draft("canonical-created-id");
|
||||
vi.mocked(authoringClient.createFromCapability).mockResolvedValue(created);
|
||||
mockedUseCapabilityDiscovery.mockReturnValue(
|
||||
controller({
|
||||
selected: {
|
||||
...summary,
|
||||
isAsync: false,
|
||||
inputSchema: {},
|
||||
outputSchema: {},
|
||||
wrapperHints: {},
|
||||
acceptsContext: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
renderRoute();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add to draft" }));
|
||||
await user.type(screen.getByRole("textbox", { name: "Workspace id" }), "requested-id");
|
||||
await user.type(screen.getByRole("textbox", { name: "Draft name" }), "seeded-report");
|
||||
await user.click(screen.getByRole("button", { name: "Create seeded draft" }));
|
||||
|
||||
expect(authoringClient.createFromCapability).toHaveBeenCalledWith({
|
||||
workspaceId: "requested-id",
|
||||
name: "seeded-report",
|
||||
title: "",
|
||||
capabilityName: "local.documents.read",
|
||||
});
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Draft destination: /console/drafts/canonical-created-id?capability=local.documents.read",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { Boxes, PackageOpen } from "lucide-react";
|
||||
import type {
|
||||
CapabilityDetail,
|
||||
CapabilitySummary,
|
||||
} from "../domain/capability-models.js";
|
||||
import { CreateDraftDialog } from "../authoring/CreateDraftDialog.js";
|
||||
import { useCapabilityDiscovery } from "./useCapabilityDiscovery.js";
|
||||
|
||||
const formatKind = (kind: CapabilitySummary["kind"]): string =>
|
||||
@@ -62,7 +64,13 @@ const CapabilityRow = ({
|
||||
</li>
|
||||
);
|
||||
|
||||
const DetailView = ({ detail }: { readonly detail: CapabilityDetail }) => (
|
||||
const DetailView = ({
|
||||
detail,
|
||||
onAddToDraft,
|
||||
}: {
|
||||
readonly detail: CapabilityDetail;
|
||||
readonly onAddToDraft: () => void;
|
||||
}) => (
|
||||
<section aria-labelledby="capability-detail-heading" className="capability-discovery__detail" id="capability-detail">
|
||||
<p className="workspace-route-pending__eyebrow">Selected contract</p>
|
||||
<h2 id="capability-detail-heading">{detail.name}</h2>
|
||||
@@ -77,11 +85,13 @@ const DetailView = ({ detail }: { readonly detail: CapabilityDetail }) => (
|
||||
<SchemaBlock heading="Output schema" value={detail.outputSchema} />
|
||||
<SchemaBlock heading="Wrapper hints" value={detail.wrapperHints} />
|
||||
</div>
|
||||
<button onClick={onAddToDraft} type="button">Add to draft</button>
|
||||
</section>
|
||||
);
|
||||
|
||||
export const DiscoverRoute = () => {
|
||||
const discovery = useCapabilityDiscovery();
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const isReady = discovery.phase === "ready";
|
||||
|
||||
return (
|
||||
@@ -164,7 +174,10 @@ export const DiscoverRoute = () => {
|
||||
</section>
|
||||
|
||||
{discovery.selected ? (
|
||||
<DetailView detail={discovery.selected} />
|
||||
<DetailView
|
||||
detail={discovery.selected}
|
||||
onAddToDraft={() => setCreateDialogOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<section aria-labelledby="capability-detail-empty-heading" className="capability-discovery__detail capability-discovery__detail--empty" id="capability-detail">
|
||||
<p className="workspace-route-pending__eyebrow">Contract detail</p>
|
||||
@@ -173,6 +186,12 @@ export const DiscoverRoute = () => {
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
{createDialogOpen && discovery.selected !== null && (
|
||||
<CreateDraftDialog
|
||||
capability={discovery.selected}
|
||||
onClose={() => setCreateDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -85,6 +85,13 @@ describe("DraftDetailRoute", () => {
|
||||
expect(screen.getAllByText("Revision 3")).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses a capability query only as the workbench's initial browser selection", () => {
|
||||
renderRoute("draft-report?capability=local.documents.read%2Fv2");
|
||||
|
||||
expect(screen.getByRole("heading", { name: "local.documents.read/v2" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Draft authoring workbench")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists the start step, step ids, and diagnostics beside the summary", () => {
|
||||
renderRoute();
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
import type {
|
||||
DraftWorkspace,
|
||||
} from "../domain/draft-workspace-models.js";
|
||||
import { DraftWorkbench } from "../authoring/DraftWorkbench.js";
|
||||
import type { WorkbenchSelection } from "../authoring/authoring-graph.js";
|
||||
import { useDraftWorkspace } from "./useDraftWorkspace.js";
|
||||
import { useCapabilityDiscovery } from "./useCapabilityDiscovery.js";
|
||||
|
||||
@@ -20,6 +21,12 @@ export const DraftDetailRoute = ({
|
||||
enableNavigationProtection = false,
|
||||
}: DraftDetailRouteProps) => {
|
||||
const { workspaceId = null } = useParams<{ workspaceId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const capabilityName = searchParams.get("capability");
|
||||
const initialSelection: WorkbenchSelection =
|
||||
capabilityName !== null && capabilityName.trim() !== ""
|
||||
? { kind: "capability", qualifiedName: capabilityName }
|
||||
: { kind: "canvas" };
|
||||
const drafts = useDraftWorkspace(workspaceId);
|
||||
const capabilities = useCapabilityDiscovery();
|
||||
const draft =
|
||||
@@ -60,6 +67,7 @@ export const DraftDetailRoute = ({
|
||||
capabilities={capabilities.items}
|
||||
draft={draft}
|
||||
enableNavigationProtection={enableNavigationProtection}
|
||||
initialSelection={initialSelection}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import { initialState } from "../../app/state.js";
|
||||
import { useConsoleWorkspace } from "../context.js";
|
||||
import {
|
||||
createDraftAuthoringClient,
|
||||
type DraftAuthoringClient,
|
||||
} from "../domain/draft-authoring-client.js";
|
||||
import type { DraftWorkspaceController } from "./useDraftWorkspace.js";
|
||||
import { useDraftWorkspace } from "./useDraftWorkspace.js";
|
||||
import { DraftIndexRoute } from "./DraftIndexRoute.js";
|
||||
@@ -10,7 +17,17 @@ vi.mock("./useDraftWorkspace.js", () => ({
|
||||
useDraftWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context.js", () => ({
|
||||
useConsoleWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../domain/draft-authoring-client.js", () => ({
|
||||
createDraftAuthoringClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseDraftWorkspace = vi.mocked(useDraftWorkspace);
|
||||
const mockedUseConsoleWorkspace = vi.mocked(useConsoleWorkspace);
|
||||
const mockedCreateDraftAuthoringClient = vi.mocked(createDraftAuthoringClient);
|
||||
|
||||
const workspace = (
|
||||
workspaceId: string,
|
||||
@@ -45,9 +62,33 @@ const controller = (
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => mockedUseDraftWorkspace.mockReturnValue(controller()));
|
||||
const authoringClient: DraftAuthoringClient = {
|
||||
createEmpty: vi.fn(),
|
||||
createFromCapability: vi.fn(),
|
||||
addCapabilityStep: vi.fn(),
|
||||
updateCapabilityStep: vi.fn(),
|
||||
setRoute: vi.fn(),
|
||||
validate: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockedUseDraftWorkspace.mockReturnValue(controller());
|
||||
mockedUseConsoleWorkspace.mockReturnValue({
|
||||
connection: initialState(),
|
||||
connectedTarget: "http://workflow.test/rpc",
|
||||
recordEvidence: vi.fn(),
|
||||
readExecutor: null,
|
||||
writeExecutor: { run: vi.fn() },
|
||||
});
|
||||
mockedCreateDraftAuthoringClient.mockReturnValue(authoringClient);
|
||||
});
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const DraftDestination = () => {
|
||||
const location = useLocation();
|
||||
return <p>Draft destination: {location.pathname}{location.search}</p>;
|
||||
};
|
||||
|
||||
describe("DraftIndexRoute", () => {
|
||||
it("shows the draft heading and a row link owned by each workspace id", () => {
|
||||
mockedUseDraftWorkspace.mockReturnValue(
|
||||
@@ -103,4 +144,31 @@ describe("DraftIndexRoute", () => {
|
||||
|
||||
expect(screen.getByText(message)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates a draft from the index and routes by the canonical workspace id", async () => {
|
||||
const user = userEvent.setup();
|
||||
const created = workspace("canonical-draft-id", { title: "Created draft" });
|
||||
vi.mocked(authoringClient.createEmpty).mockResolvedValue(created);
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/console/drafts"]}>
|
||||
<Routes>
|
||||
<Route path="/console/drafts" element={<DraftIndexRoute />} />
|
||||
<Route path="/console/drafts/:workspaceId" element={<DraftDestination />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "New draft" }));
|
||||
await user.type(screen.getByRole("textbox", { name: "Workspace id" }), "requested-draft");
|
||||
await user.type(screen.getByRole("textbox", { name: "Draft name" }), "report-workflow");
|
||||
await user.type(screen.getByRole("textbox", { name: "Title" }), "Created draft");
|
||||
await user.click(screen.getByRole("button", { name: "Create draft" }));
|
||||
|
||||
expect(authoringClient.createEmpty).toHaveBeenCalledWith({
|
||||
workspaceId: "requested-draft",
|
||||
name: "report-workflow",
|
||||
title: "Created draft",
|
||||
});
|
||||
expect(await screen.findByText("Draft destination: /console/drafts/canonical-draft-id")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import { CreateDraftDialog } from "../authoring/CreateDraftDialog.js";
|
||||
import { useDraftWorkspace } from "./useDraftWorkspace.js";
|
||||
|
||||
const titleFor = (workspace: DraftWorkspace): string =>
|
||||
@@ -29,6 +31,7 @@ const DraftRow = ({ workspace }: { readonly workspace: DraftWorkspace }) => (
|
||||
|
||||
export const DraftIndexRoute = () => {
|
||||
const drafts = useDraftWorkspace(null);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="draft-workspaces">
|
||||
@@ -36,9 +39,14 @@ export const DraftIndexRoute = () => {
|
||||
<p className="workspace-route-pending__eyebrow">Authoring inventory</p>
|
||||
<h1>Draft workspaces</h1>
|
||||
<p>Inspect saved workflow drafts without changing their definitions.</p>
|
||||
<button onClick={drafts.refresh} type="button">
|
||||
Refresh drafts
|
||||
</button>
|
||||
<div className="draft-workspaces__actions">
|
||||
<button onClick={() => setCreateDialogOpen(true)} type="button">
|
||||
New draft
|
||||
</button>
|
||||
<button onClick={drafts.refresh} type="button">
|
||||
Refresh drafts
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="draft-workspaces-list-heading" className="draft-workspaces__panel">
|
||||
@@ -85,6 +93,9 @@ export const DraftIndexRoute = () => {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{createDialogOpen && (
|
||||
<CreateDraftDialog capability={null} onClose={() => setCreateDialogOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user