feat: add draft workspace routes
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import type { DraftWorkspaceController } from "./useDraftWorkspace.js";
|
||||
import { useDraftWorkspace } from "./useDraftWorkspace.js";
|
||||
import { DraftDetailRoute } from "./DraftDetailRoute.js";
|
||||
|
||||
vi.mock("./useDraftWorkspace.js", () => ({
|
||||
useDraftWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseDraftWorkspace = vi.mocked(useDraftWorkspace);
|
||||
|
||||
const workspace = (overrides: Partial<DraftWorkspace> = {}): DraftWorkspace => ({
|
||||
workspaceId: "draft-report",
|
||||
revision: 3,
|
||||
title: "Quarterly report",
|
||||
status: "invalid",
|
||||
diagnostics: [
|
||||
{
|
||||
code: "missing_outcome_edge",
|
||||
path: "nodes[summarize].routes",
|
||||
message: "The summarize step is missing its ok route.",
|
||||
stepId: "summarize",
|
||||
repairHint: "Route summarize.ok to __end__.",
|
||||
details: {},
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
name: "report-workflow",
|
||||
start: "collect",
|
||||
stepCount: 2,
|
||||
routeCount: 1,
|
||||
steps: ["collect", "summarize"],
|
||||
},
|
||||
draft: {
|
||||
nodes: [{ id: "collect" }, { id: "summarize" }],
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const controller = (
|
||||
overrides: Partial<DraftWorkspaceController> = {},
|
||||
): DraftWorkspaceController => ({
|
||||
listPhase: "ready",
|
||||
detailPhase: "ready",
|
||||
items: [],
|
||||
selected: workspace(),
|
||||
listMessage: null,
|
||||
detailMessage: null,
|
||||
refresh: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const renderRoute = (workspaceId = "draft-report") =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`/console/drafts/${workspaceId}`]}>
|
||||
<Routes>
|
||||
<Route path="/console/drafts/:workspaceId" element={<DraftDetailRoute />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
beforeEach(() => mockedUseDraftWorkspace.mockReturnValue(controller()));
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("DraftDetailRoute", () => {
|
||||
it("uses the URL workspace id and shows prominent status and revision facts", () => {
|
||||
renderRoute();
|
||||
|
||||
expect(mockedUseDraftWorkspace).toHaveBeenCalledWith("draft-report");
|
||||
const breadcrumbs = screen.getByRole("navigation", { name: "Draft breadcrumbs" });
|
||||
expect(breadcrumbs).toHaveTextContent("Drafts");
|
||||
expect(breadcrumbs).toHaveTextContent("draft-report");
|
||||
expect(screen.getByRole("heading", { name: "Quarterly report" })).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Invalid")).not.toHaveLength(0);
|
||||
expect(screen.getAllByText("Revision 3")).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("lists the start step, step ids, and diagnostics beside the summary", () => {
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getAllByText("collect")).not.toHaveLength(0);
|
||||
expect(screen.getAllByText("summarize")).not.toHaveLength(0);
|
||||
expect(screen.getByText("missing_outcome_edge")).toBeInTheDocument();
|
||||
expect(screen.getByText("nodes[summarize].routes")).toBeInTheDocument();
|
||||
expect(screen.getByText("The summarize step is missing its ok route.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Step id")).toBeInTheDocument();
|
||||
expect(screen.getByText("Repair hint")).toBeInTheDocument();
|
||||
expect(screen.getByText("Route summarize.ok to __end__.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the raw draft closed by default and exposes no mutation controls", () => {
|
||||
const { container } = renderRoute();
|
||||
|
||||
const details = container.querySelector("details");
|
||||
expect(details).not.toBeNull();
|
||||
expect(details).not.toHaveAttribute("open");
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: /compile|artifact|save|edit|mutate/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("explains when the full draft document was not returned", () => {
|
||||
mockedUseDraftWorkspace.mockReturnValue(
|
||||
controller({ selected: workspace({ draft: null }) }),
|
||||
);
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByText("Full draft document was not returned")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["disconnected", "Connect a workflow server to view this draft.", { detailPhase: "disconnected" as const, selected: null }],
|
||||
["loading", "Loading draft workspace...", { detailPhase: "loading" as const, selected: null }],
|
||||
["error", "DraftWorkspace is malformed", { detailPhase: "error" as const, selected: null, detailMessage: "DraftWorkspace is malformed" }],
|
||||
] as const)("renders an explicit %s state", (_name, message, overrides) => {
|
||||
mockedUseDraftWorkspace.mockReturnValue(controller(overrides));
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByText(message)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import type {
|
||||
DraftDiagnostic,
|
||||
DraftWorkspace,
|
||||
} from "../domain/draft-workspace-models.js";
|
||||
import { useDraftWorkspace } from "./useDraftWorkspace.js";
|
||||
|
||||
const MAX_RAW_DRAFT_CHARS = 12_000;
|
||||
|
||||
const titleFor = (workspace: DraftWorkspace): string =>
|
||||
workspace.title?.trim() || workspace.workspaceId;
|
||||
|
||||
const formatStatus = (status: DraftWorkspace["status"]): string =>
|
||||
status.charAt(0).toUpperCase() + status.slice(1);
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (typeof value === "string") return value;
|
||||
const encoded = JSON.stringify(value);
|
||||
return encoded ?? String(value);
|
||||
};
|
||||
|
||||
const boundedJson = (value: Record<string, unknown>): string => {
|
||||
const encoded = JSON.stringify(value, null, 2);
|
||||
if (encoded.length <= MAX_RAW_DRAFT_CHARS) return encoded;
|
||||
return `${encoded.slice(0, MAX_RAW_DRAFT_CHARS)}\n... truncated ...`;
|
||||
};
|
||||
|
||||
const Fact = ({ label, value }: { readonly label: string; readonly value: string }) => (
|
||||
<div>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Diagnostic = ({ diagnostic }: { readonly diagnostic: DraftDiagnostic }) => (
|
||||
<li className="draft-detail__diagnostic">
|
||||
<dl>
|
||||
<Fact label="Code" value={diagnostic.code} />
|
||||
<Fact label="Path" value={diagnostic.path} />
|
||||
<Fact label="Message" value={diagnostic.message} />
|
||||
<Fact label="Step id" value={diagnostic.stepId ?? "none"} />
|
||||
<Fact label="Repair hint" value={diagnostic.repairHint ?? "none"} />
|
||||
</dl>
|
||||
</li>
|
||||
);
|
||||
|
||||
const DraftFacts = ({ draft }: { readonly draft: DraftWorkspace }) => (
|
||||
<section aria-labelledby="draft-detail-summary-heading" className="draft-detail__panel">
|
||||
<h2 id="draft-detail-summary-heading">Draft summary</h2>
|
||||
<dl className="draft-detail__facts">
|
||||
<Fact label="Status" value={formatStatus(draft.status)} />
|
||||
<Fact label="Revision" value={`Revision ${draft.revision}`} />
|
||||
<Fact label="Start step" value={formatValue(draft.summary.start)} />
|
||||
<Fact label="Step count" value={String(draft.summary.stepCount)} />
|
||||
<Fact label="Route count" value={String(draft.summary.routeCount)} />
|
||||
</dl>
|
||||
|
||||
<h3>Step ids</h3>
|
||||
<ul className="draft-detail__steps">
|
||||
{draft.summary.steps.map((stepId) => <li key={stepId}>{stepId}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
|
||||
const Diagnostics = ({ diagnostics }: { readonly diagnostics: ReadonlyArray<DraftDiagnostic> }) => (
|
||||
<section aria-labelledby="draft-detail-diagnostics-heading" className="draft-detail__panel">
|
||||
<h2 id="draft-detail-diagnostics-heading">Diagnostics</h2>
|
||||
{diagnostics.length > 0 ? (
|
||||
<ol className="draft-detail__diagnostics">
|
||||
{diagnostics.map((diagnostic, index) => (
|
||||
<Diagnostic key={`${diagnostic.code}-${diagnostic.path}-${index}`} diagnostic={diagnostic} />
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p>No diagnostics reported.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
const RawDraft = ({ draft }: { readonly draft: Record<string, unknown> | null }) => (
|
||||
<details className="draft-detail__raw">
|
||||
<summary>Raw draft document</summary>
|
||||
{draft ? (
|
||||
<pre>{boundedJson(draft)}</pre>
|
||||
) : (
|
||||
<p>Full draft document was not returned</p>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
|
||||
export const DraftDetailRoute = () => {
|
||||
const { workspaceId = null } = useParams<{ workspaceId: string }>();
|
||||
const drafts = useDraftWorkspace(workspaceId);
|
||||
const draft = drafts.selected;
|
||||
|
||||
return (
|
||||
<div className="draft-detail">
|
||||
<nav aria-label="Draft breadcrumbs" className="draft-detail__breadcrumbs">
|
||||
<Link to="/console/drafts">Drafts</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span>{workspaceId ?? "Unknown workspace"}</span>
|
||||
</nav>
|
||||
|
||||
{drafts.detailPhase === "disconnected" && (
|
||||
<p role="status">Connect a workflow server to view this draft.</p>
|
||||
)}
|
||||
{drafts.detailPhase === "loading" && <p role="status">Loading draft workspace...</p>}
|
||||
{drafts.detailPhase === "error" && (
|
||||
<p role="alert">{drafts.detailMessage ?? "Draft workspace detail failed."}</p>
|
||||
)}
|
||||
{drafts.detailPhase === "idle" && <p role="status">Select a draft workspace to inspect.</p>}
|
||||
|
||||
{draft && (
|
||||
<>
|
||||
<header className="draft-detail__header">
|
||||
<p className="workspace-route-pending__eyebrow">Read-only draft</p>
|
||||
<h1>{titleFor(draft)}</h1>
|
||||
<p className="draft-detail__workspace-id">{draft.workspaceId}</p>
|
||||
<p className="draft-detail__status-line">
|
||||
<span className="draft-workspaces__status" data-status={draft.status}>
|
||||
{formatStatus(draft.status)}
|
||||
</span>
|
||||
<span>Revision {draft.revision}</span>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="draft-detail__panels">
|
||||
<DraftFacts draft={draft} />
|
||||
<Diagnostics diagnostics={draft.diagnostics} />
|
||||
</div>
|
||||
<RawDraft draft={draft.draft} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import type { DraftWorkspaceController } from "./useDraftWorkspace.js";
|
||||
import { useDraftWorkspace } from "./useDraftWorkspace.js";
|
||||
import { DraftIndexRoute } from "./DraftIndexRoute.js";
|
||||
|
||||
vi.mock("./useDraftWorkspace.js", () => ({
|
||||
useDraftWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseDraftWorkspace = vi.mocked(useDraftWorkspace);
|
||||
|
||||
const workspace = (
|
||||
workspaceId: string,
|
||||
overrides: Partial<DraftWorkspace> = {},
|
||||
): DraftWorkspace => ({
|
||||
workspaceId,
|
||||
revision: 4,
|
||||
title: "Quarterly report",
|
||||
status: "invalid",
|
||||
diagnostics: [],
|
||||
summary: {
|
||||
name: "report-workflow",
|
||||
start: "collect",
|
||||
stepCount: 3,
|
||||
routeCount: 4,
|
||||
steps: ["collect", "summarize", "publish"],
|
||||
},
|
||||
draft: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const controller = (
|
||||
overrides: Partial<DraftWorkspaceController> = {},
|
||||
): DraftWorkspaceController => ({
|
||||
listPhase: "ready",
|
||||
detailPhase: "idle",
|
||||
items: [workspace("draft-report")],
|
||||
selected: null,
|
||||
listMessage: null,
|
||||
detailMessage: null,
|
||||
refresh: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => mockedUseDraftWorkspace.mockReturnValue(controller()));
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("DraftIndexRoute", () => {
|
||||
it("shows the draft heading and a row link owned by each workspace id", () => {
|
||||
mockedUseDraftWorkspace.mockReturnValue(
|
||||
controller({
|
||||
items: [
|
||||
workspace("draft-report"),
|
||||
workspace("draft-no-title", { title: null }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DraftIndexRoute />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Draft workspaces" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Quarterly report" })).toHaveAttribute(
|
||||
"href",
|
||||
"/console/drafts/draft-report",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "draft-no-title" })).toHaveAttribute(
|
||||
"href",
|
||||
"/console/drafts/draft-no-title",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders revision, status, step count, and route count in each row", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DraftIndexRoute />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const row = screen.getByRole("row", { name: /Quarterly report/i });
|
||||
expect(row).toHaveTextContent("Revision 4");
|
||||
expect(row).toHaveTextContent("Invalid");
|
||||
expect(row).toHaveTextContent("3 steps");
|
||||
expect(row).toHaveTextContent("4 routes");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["empty", "No draft workspaces are available.", { items: [], listPhase: "ready" as const }],
|
||||
["error", "DraftWorkspacePage is malformed", { items: [], listPhase: "error" as const, listMessage: "DraftWorkspacePage is malformed" }],
|
||||
] as const)("renders an explicit %s state", (_name, message, overrides) => {
|
||||
mockedUseDraftWorkspace.mockReturnValue(controller(overrides));
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DraftIndexRoute />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(message)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import { useDraftWorkspace } from "./useDraftWorkspace.js";
|
||||
|
||||
const titleFor = (workspace: DraftWorkspace): string =>
|
||||
workspace.title?.trim() || workspace.workspaceId;
|
||||
|
||||
const formatStatus = (status: DraftWorkspace["status"]): string =>
|
||||
status.charAt(0).toUpperCase() + status.slice(1);
|
||||
|
||||
const DraftRow = ({ workspace }: { readonly workspace: DraftWorkspace }) => (
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<Link to={`/console/drafts/${encodeURIComponent(workspace.workspaceId)}`}>
|
||||
{titleFor(workspace)}
|
||||
</Link>
|
||||
<span className="draft-workspaces__row-id">{workspace.workspaceId}</span>
|
||||
</th>
|
||||
<td>
|
||||
<span className="draft-workspaces__status" data-status={workspace.status}>
|
||||
{formatStatus(workspace.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td>Revision {workspace.revision}</td>
|
||||
<td>{workspace.summary.stepCount} steps</td>
|
||||
<td>{workspace.summary.routeCount} routes</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
export const DraftIndexRoute = () => {
|
||||
const drafts = useDraftWorkspace(null);
|
||||
|
||||
return (
|
||||
<div className="draft-workspaces">
|
||||
<header className="draft-workspaces__header">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="draft-workspaces-list-heading" className="draft-workspaces__panel">
|
||||
<div className="draft-workspaces__section-heading">
|
||||
<div>
|
||||
<p className="workspace-route-pending__eyebrow">Read-only index</p>
|
||||
<h2 id="draft-workspaces-list-heading">Available drafts</h2>
|
||||
</div>
|
||||
{drafts.items.length > 0 && (
|
||||
<span className="draft-workspaces__count">{drafts.items.length} shown</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{drafts.listPhase === "disconnected" && (
|
||||
<p role="status">Connect a workflow server to list draft workspaces.</p>
|
||||
)}
|
||||
{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 draft workspaces are available.</p>
|
||||
)}
|
||||
|
||||
{drafts.items.length > 0 && (
|
||||
<div className="draft-workspaces__table-wrap">
|
||||
<table>
|
||||
<caption className="visually-hidden">Draft workspace inventory</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Workspace</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Revision</th>
|
||||
<th scope="col">Steps</th>
|
||||
<th scope="col">Routes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{drafts.items.map((workspace) => (
|
||||
<DraftRow key={workspace.workspaceId} workspace={workspace} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectionState } from "../../app/state.js";
|
||||
import { useConsoleWorkspace } from "../context.js";
|
||||
import type { DraftWorkspaceClient } from "../domain/draft-workspace-client.js";
|
||||
import type {
|
||||
DraftWorkspace,
|
||||
DraftWorkspacePage,
|
||||
} from "../domain/draft-workspace-models.js";
|
||||
import { createDraftWorkspaceClient } from "../domain/draft-workspace-client.js";
|
||||
import type { ConsoleReadExecutor } from "../domain/read-executor.js";
|
||||
import { useDraftWorkspace } from "./useDraftWorkspace.js";
|
||||
|
||||
vi.mock("../context.js", () => ({
|
||||
useConsoleWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../domain/draft-workspace-client.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../domain/draft-workspace-client.js")>(
|
||||
"../domain/draft-workspace-client.js",
|
||||
);
|
||||
return { ...actual, createDraftWorkspaceClient: vi.fn() };
|
||||
});
|
||||
|
||||
const mockedUseConsoleWorkspace = vi.mocked(useConsoleWorkspace);
|
||||
const mockedCreateDraftWorkspaceClient = vi.mocked(createDraftWorkspaceClient);
|
||||
|
||||
const connectedState = {
|
||||
phase: "connected",
|
||||
connectedTarget: "http://workflow.example/rpc",
|
||||
} as ConnectionState;
|
||||
|
||||
const disconnectedState = {
|
||||
phase: "not_configured",
|
||||
connectedTarget: null,
|
||||
} as ConnectionState;
|
||||
|
||||
const workspace = (
|
||||
workspaceId: string,
|
||||
overrides: Partial<DraftWorkspace> = {},
|
||||
): DraftWorkspace => ({
|
||||
workspaceId,
|
||||
revision: 1,
|
||||
title: `${workspaceId} title`,
|
||||
status: "valid",
|
||||
diagnostics: [],
|
||||
summary: {
|
||||
name: workspaceId,
|
||||
start: "start",
|
||||
stepCount: 1,
|
||||
routeCount: 1,
|
||||
steps: ["start"],
|
||||
},
|
||||
draft: { nodes: [] },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const page = (items: ReadonlyArray<DraftWorkspace>): DraftWorkspacePage => ({
|
||||
items: [...items],
|
||||
});
|
||||
|
||||
const deferred = <T,>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
const client = {
|
||||
list: vi.fn<DraftWorkspaceClient["list"]>(),
|
||||
load: vi.fn<DraftWorkspaceClient["load"]>(),
|
||||
} satisfies DraftWorkspaceClient;
|
||||
|
||||
const readExecutor = {} as ConsoleReadExecutor;
|
||||
|
||||
beforeEach(() => {
|
||||
client.list.mockReset();
|
||||
client.load.mockReset();
|
||||
mockedCreateDraftWorkspaceClient.mockReset();
|
||||
mockedCreateDraftWorkspaceClient.mockReturnValue(client);
|
||||
mockedUseConsoleWorkspace.mockReturnValue({
|
||||
connection: connectedState,
|
||||
connectedTarget: connectedState.connectedTarget,
|
||||
recordEvidence: vi.fn(),
|
||||
readExecutor,
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDraftWorkspace", () => {
|
||||
it("loads the list and URL-owned detail through the draft client", async () => {
|
||||
client.list.mockResolvedValue(page([workspace("draft-report")]));
|
||||
client.load.mockResolvedValue(workspace("draft-report"));
|
||||
|
||||
const { result } = renderHook(() => useDraftWorkspace("draft-report"));
|
||||
|
||||
await waitFor(() => expect(result.current.listPhase).toBe("ready"));
|
||||
await waitFor(() => expect(result.current.detailPhase).toBe("ready"));
|
||||
|
||||
expect(client.list).toHaveBeenCalledOnce();
|
||||
expect(client.load).toHaveBeenCalledWith("draft-report");
|
||||
expect(result.current.items[0]?.workspaceId).toBe("draft-report");
|
||||
expect(result.current.selected?.workspaceId).toBe("draft-report");
|
||||
});
|
||||
|
||||
it("ignores a late detail response after the URL workspace changes", async () => {
|
||||
client.list.mockResolvedValue(page([]));
|
||||
const firstDetail = deferred<DraftWorkspace>();
|
||||
const secondDetail = deferred<DraftWorkspace>();
|
||||
client.load
|
||||
.mockReturnValueOnce(firstDetail.promise)
|
||||
.mockReturnValueOnce(secondDetail.promise);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ workspaceId }: { workspaceId: string | null }) => useDraftWorkspace(workspaceId),
|
||||
{ initialProps: { workspaceId: "draft-first" } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(client.load).toHaveBeenCalledWith("draft-first"));
|
||||
rerender({ workspaceId: "draft-second" });
|
||||
await waitFor(() => expect(client.load).toHaveBeenCalledWith("draft-second"));
|
||||
|
||||
firstDetail.resolve(workspace("draft-first"));
|
||||
secondDetail.resolve(workspace("draft-second"));
|
||||
|
||||
await waitFor(() => expect(result.current.selected?.workspaceId).toBe("draft-second"));
|
||||
expect(result.current.selected?.workspaceId).not.toBe("draft-first");
|
||||
});
|
||||
|
||||
it("preserves the loaded list while refresh reloads the relevant reads", async () => {
|
||||
client.list
|
||||
.mockResolvedValueOnce(page([workspace("draft-old")]))
|
||||
.mockReturnValueOnce(deferred<DraftWorkspacePage>().promise);
|
||||
client.load
|
||||
.mockResolvedValueOnce(workspace("draft-old"))
|
||||
.mockReturnValueOnce(deferred<DraftWorkspace>().promise);
|
||||
|
||||
const { result } = renderHook(() => useDraftWorkspace("draft-old"));
|
||||
await waitFor(() => expect(result.current.selected?.workspaceId).toBe("draft-old"));
|
||||
|
||||
act(() => result.current.refresh());
|
||||
|
||||
expect(result.current.items[0]?.workspaceId).toBe("draft-old");
|
||||
expect(client.list).toHaveBeenCalledTimes(2);
|
||||
expect(client.load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("clears stale data and reloads both reads after reconnect", async () => {
|
||||
client.list
|
||||
.mockResolvedValueOnce(page([workspace("draft-old")]))
|
||||
.mockResolvedValueOnce(page([workspace("draft-new")]))
|
||||
.mockResolvedValueOnce(page([workspace("draft-new")]));
|
||||
client.load
|
||||
.mockResolvedValueOnce(workspace("draft-old"))
|
||||
.mockResolvedValueOnce(workspace("draft-new"));
|
||||
|
||||
const { result, rerender } = renderHook(() => useDraftWorkspace("draft-new"));
|
||||
await waitFor(() => expect(result.current.listPhase).toBe("ready"));
|
||||
await waitFor(() => expect(result.current.detailPhase).toBe("ready"));
|
||||
|
||||
mockedUseConsoleWorkspace.mockReturnValue({
|
||||
connection: connectedState,
|
||||
connectedTarget: "http://new-workflow.example/rpc",
|
||||
recordEvidence: vi.fn(),
|
||||
readExecutor: {} as ConsoleReadExecutor,
|
||||
});
|
||||
rerender();
|
||||
|
||||
expect(result.current.items).toEqual([]);
|
||||
expect(result.current.selected).toBeNull();
|
||||
await waitFor(() => expect(result.current.items[0]?.workspaceId).toBe("draft-new"));
|
||||
expect(client.list).toHaveBeenCalledTimes(2);
|
||||
expect(client.load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports list and detail errors without claiming ready data", async () => {
|
||||
client.list.mockRejectedValue(new Error("list failed"));
|
||||
client.load.mockRejectedValue(new Error("detail failed"));
|
||||
|
||||
const { result } = renderHook(() => useDraftWorkspace("draft-broken"));
|
||||
|
||||
await waitFor(() => expect(result.current.listPhase).toBe("error"));
|
||||
await waitFor(() => expect(result.current.detailPhase).toBe("error"));
|
||||
|
||||
expect(result.current.items).toEqual([]);
|
||||
expect(result.current.selected).toBeNull();
|
||||
expect(result.current.listMessage).toBe("list failed");
|
||||
expect(result.current.detailMessage).toBe("detail failed");
|
||||
});
|
||||
|
||||
it("does not request drafts while disconnected", () => {
|
||||
mockedUseConsoleWorkspace.mockReturnValue({
|
||||
connection: disconnectedState,
|
||||
connectedTarget: null,
|
||||
recordEvidence: vi.fn(),
|
||||
readExecutor: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDraftWorkspace("draft-report"));
|
||||
|
||||
expect(result.current.listPhase).toBe("disconnected");
|
||||
expect(result.current.detailPhase).toBe("disconnected");
|
||||
expect(client.list).not.toHaveBeenCalled();
|
||||
expect(client.load).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useConsoleWorkspace } from "../context.js";
|
||||
import {
|
||||
createDraftWorkspaceClient,
|
||||
type DraftWorkspaceClient,
|
||||
} from "../domain/draft-workspace-client.js";
|
||||
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
|
||||
export type DraftLoadPhase =
|
||||
| "disconnected"
|
||||
| "idle"
|
||||
| "loading"
|
||||
| "ready"
|
||||
| "error";
|
||||
|
||||
export type DraftWorkspaceController = {
|
||||
readonly listPhase: DraftLoadPhase;
|
||||
readonly detailPhase: DraftLoadPhase;
|
||||
readonly items: ReadonlyArray<DraftWorkspace>;
|
||||
readonly selected: DraftWorkspace | null;
|
||||
readonly listMessage: string | null;
|
||||
readonly detailMessage: string | null;
|
||||
readonly refresh: () => void;
|
||||
};
|
||||
|
||||
type DraftWorkspaceState = Omit<DraftWorkspaceController, "refresh">;
|
||||
|
||||
const initialState: DraftWorkspaceState = {
|
||||
listPhase: "disconnected",
|
||||
detailPhase: "disconnected",
|
||||
items: [],
|
||||
selected: null,
|
||||
listMessage: null,
|
||||
detailMessage: null,
|
||||
};
|
||||
|
||||
const errorMessage = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
export const useDraftWorkspace = (
|
||||
workspaceId: string | null,
|
||||
): DraftWorkspaceController => {
|
||||
const { connectedTarget, readExecutor } = useConsoleWorkspace();
|
||||
const client = useMemo<DraftWorkspaceClient | null>(
|
||||
() => (readExecutor ? createDraftWorkspaceClient(readExecutor) : null),
|
||||
[readExecutor],
|
||||
);
|
||||
const [state, setState] = useState<DraftWorkspaceState>(initialState);
|
||||
const listGenerationRef = useRef(0);
|
||||
const detailGenerationRef = useRef(0);
|
||||
const previousWorkspaceIdRef = useRef(workspaceId);
|
||||
const currentWorkspaceIdRef = useRef(workspaceId);
|
||||
currentWorkspaceIdRef.current = workspaceId;
|
||||
|
||||
const runList = useCallback(() => {
|
||||
if (!client || !connectedTarget) return;
|
||||
const generation = ++listGenerationRef.current;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
listPhase: "loading",
|
||||
listMessage: null,
|
||||
}));
|
||||
|
||||
void client
|
||||
.list()
|
||||
.then((page) => {
|
||||
if (generation !== listGenerationRef.current) return;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
listPhase: "ready",
|
||||
items: page.items,
|
||||
listMessage: null,
|
||||
}));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (generation !== listGenerationRef.current) return;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
listPhase: "error",
|
||||
listMessage: errorMessage(error),
|
||||
}));
|
||||
});
|
||||
}, [client, connectedTarget]);
|
||||
|
||||
const runDetail = useCallback(
|
||||
(nextWorkspaceId: string | null) => {
|
||||
const generation = ++detailGenerationRef.current;
|
||||
if (!nextWorkspaceId) {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
detailPhase: client && connectedTarget ? "idle" : "disconnected",
|
||||
selected: null,
|
||||
detailMessage: null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (!client || !connectedTarget) {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
detailPhase: "disconnected",
|
||||
selected: null,
|
||||
detailMessage: null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => ({
|
||||
...current,
|
||||
detailPhase: "loading",
|
||||
selected: null,
|
||||
detailMessage: null,
|
||||
}));
|
||||
|
||||
void client
|
||||
.load(nextWorkspaceId)
|
||||
.then((detail) => {
|
||||
if (generation !== detailGenerationRef.current) return;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
detailPhase: "ready",
|
||||
selected: detail,
|
||||
detailMessage: null,
|
||||
}));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (generation !== detailGenerationRef.current) return;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
detailPhase: "error",
|
||||
selected: null,
|
||||
detailMessage: errorMessage(error),
|
||||
}));
|
||||
});
|
||||
},
|
||||
[client, connectedTarget],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !connectedTarget) {
|
||||
listGenerationRef.current++;
|
||||
detailGenerationRef.current++;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
listPhase: "disconnected",
|
||||
detailPhase: "disconnected",
|
||||
items: [],
|
||||
selected: null,
|
||||
listMessage: null,
|
||||
detailMessage: null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// A new executor represents a fresh connection. Do not show data from the
|
||||
// old server while the list and URL-owned detail are being reloaded.
|
||||
setState((current) => ({
|
||||
...current,
|
||||
items: [],
|
||||
selected: null,
|
||||
listMessage: null,
|
||||
detailMessage: null,
|
||||
}));
|
||||
runList();
|
||||
runDetail(currentWorkspaceIdRef.current);
|
||||
}, [client, connectedTarget, runDetail, runList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousWorkspaceIdRef.current === workspaceId) return;
|
||||
previousWorkspaceIdRef.current = workspaceId;
|
||||
runDetail(workspaceId);
|
||||
}, [runDetail, workspaceId]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!client || !connectedTarget) return;
|
||||
runList();
|
||||
runDetail(workspaceId);
|
||||
}, [client, connectedTarget, runDetail, runList, workspaceId]);
|
||||
|
||||
return { ...state, refresh };
|
||||
};
|
||||
Reference in New Issue
Block a user