feat: add draft workspace routes

This commit is contained in:
lda
2026-08-04 22:24:30 +07:00 Verified
parent 2facae87a8
commit fd33bc5301
9 changed files with 1179 additions and 2 deletions
@@ -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 };
};