fix: sync draft route freshness

This commit is contained in:
lda
2026-08-10 01:33:48 +07:00 Verified
parent 14892e89c3
commit ee70397653
4 changed files with 151 additions and 6 deletions
@@ -115,6 +115,13 @@ describe("DraftWorkbench", () => {
expect(screen.getByText("Review needs a route.")).toBeInTheDocument();
});
it("reports the controller's current canonical draft to its route owner", () => {
const onDraftChange = vi.fn();
render(<DraftWorkbench draft={workspace} onDraftChange={onDraftChange} />);
expect(onDraftChange).toHaveBeenCalledWith(workspace);
});
it("keeps the raw draft collapsed and exposes all deferred actions without handlers", () => {
render(<DraftWorkbench draft={workspace} />);
@@ -13,6 +13,7 @@ type DraftWorkbenchProps = {
readonly draft: DraftWorkspace;
readonly capabilities?: ReadonlyArray<CapabilitySummary>;
readonly initialSelection?: WorkbenchSelection;
readonly onDraftChange?: (draft: DraftWorkspace) => void;
readonly onSelectionChange?: (selection: WorkbenchSelection) => void;
readonly enableNavigationProtection?: boolean;
};
@@ -130,6 +131,7 @@ export const DraftWorkbench = ({
draft,
capabilities = EMPTY_CAPABILITIES,
initialSelection = { kind: "canvas" },
onDraftChange,
onSelectionChange,
enableNavigationProtection = false,
}: DraftWorkbenchProps) => {
@@ -138,6 +140,9 @@ export const DraftWorkbench = ({
const [openSheet, setOpenSheet] = useState<MobileSheet | null>(null);
const paletteTriggerRef = useRef<HTMLButtonElement>(null);
const inspectorTriggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
onDraftChange?.(controller.draft);
}, [controller.draft, onDraftChange]);
// Resolve capability details from the controller draft so a newly committed
// node can immediately render its edit form before the route-level loader refreshes.
const graph = projectAuthoringGraph(controller.draft.draft);
@@ -0,0 +1,111 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Link, MemoryRouter, Route, Routes } from "react-router-dom";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import { useDraftWorkspace } from "./useDraftWorkspace.js";
import type { DraftWorkspaceController } from "./useDraftWorkspace.js";
import { DraftDetailRoute } from "./DraftDetailRoute.js";
vi.mock("./useDraftWorkspace.js", () => ({
useDraftWorkspace: vi.fn(),
}));
vi.mock("../authoring/DraftWorkbench.js", () => ({
DraftWorkbench: ({
draft,
onDraftChange,
}: {
readonly draft: DraftWorkspace;
readonly onDraftChange?: (draft: DraftWorkspace) => void;
}) => (
<section aria-label="Mock draft workbench">
<button
onClick={() => onDraftChange?.({ ...draft, revision: 4, status: "valid" })}
type="button"
>
Commit current draft
</button>
<Link to="/console/drafts/other">Open other workspace</Link>
</section>
),
}));
const mockedUseDraftWorkspace = vi.mocked(useDraftWorkspace);
let loadedReport: DraftWorkspace;
let loadedOther: DraftWorkspace;
const workspace = (overrides: Partial<DraftWorkspace> = {}): DraftWorkspace => ({
workspaceId: "draft-report",
revision: 1,
title: "Report",
status: "invalid",
diagnostics: [],
summary: { name: "report", start: "collect", stepCount: 1, routeCount: 0, steps: ["collect"] },
draft: { nodes: [{ id: "collect" }] },
...overrides,
});
const controller = (selected: DraftWorkspace | null): DraftWorkspaceController => ({
listPhase: "ready",
detailPhase: selected === null ? "loading" : "ready",
items: [],
selected,
listMessage: null,
detailMessage: null,
refresh: vi.fn(),
});
const renderRoute = () => render(
<MemoryRouter initialEntries={["/console/drafts/draft-report"]}>
<Routes>
<Route path="/console/drafts/:workspaceId" element={<DraftDetailRoute />} />
</Routes>
</MemoryRouter>,
);
beforeEach(() => {
loadedReport = workspace();
loadedOther = workspace({ workspaceId: "other", title: "Other", revision: 9, status: "valid" });
mockedUseDraftWorkspace.mockImplementation((workspaceId) => controller(
workspaceId === "draft-report"
? loadedReport
: loadedOther,
));
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("DraftDetailRoute authoring freshness", () => {
it("reflects committed workbench state, accepts loader refreshes, and clears it across workspaces", async () => {
const user = userEvent.setup();
const route = renderRoute();
expect(screen.getByText("Revision 1")).toBeInTheDocument();
expect(screen.getByText("Invalid")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Commit current draft" }));
expect(screen.getByText("Revision 4")).toBeInTheDocument();
expect(screen.getByText("Valid")).toBeInTheDocument();
loadedReport = workspace({ revision: 7, status: "invalid" });
route.rerender(
<MemoryRouter initialEntries={["/console/drafts/draft-report"]}>
<Routes>
<Route path="/console/drafts/:workspaceId" element={<DraftDetailRoute />} />
</Routes>
</MemoryRouter>,
);
await waitFor(() => expect(screen.getByText("Revision 7")).toBeInTheDocument());
expect(screen.getByText("Invalid")).toBeInTheDocument();
await user.click(screen.getByRole("link", { name: "Open other workspace" }));
expect(screen.getByRole("heading", { name: "Other" })).toBeInTheDocument();
expect(screen.getByText("Revision 9")).toBeInTheDocument();
expect(screen.getByText("Valid")).toBeInTheDocument();
expect(screen.queryByText("Revision 4")).toBeNull();
});
});
@@ -1,3 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useParams, useSearchParams } from "react-router-dom";
import type {
DraftWorkspace,
@@ -31,6 +32,26 @@ export const DraftDetailRoute = ({
const capabilities = useCapabilityDiscovery({ loadAllPages: true });
const draft =
drafts.selected?.workspaceId === workspaceId ? drafts.selected : null;
const [authoringDraft, setAuthoringDraft] = useState<DraftWorkspace | null>(null);
useEffect(() => {
setAuthoringDraft(draft);
}, [draft, drafts.detailPhase, workspaceId]);
// A successful mutation is displayed immediately; a later loader snapshot
// replaces it, and workspace ids prevent an old callback crossing routes.
const displayedDraft =
draft === null
? null
: authoringDraft?.workspaceId === workspaceId
? authoringDraft
: draft;
const handleDraftChange = useCallback(
(nextDraft: DraftWorkspace): void => {
if (nextDraft.workspaceId === workspaceId) setAuthoringDraft(nextDraft);
},
[workspaceId],
);
return (
<div className="draft-detail">
@@ -49,17 +70,17 @@ export const DraftDetailRoute = ({
)}
{drafts.detailPhase === "idle" && <p role="status">Select a draft workspace to inspect.</p>}
{draft && (
{draft && displayedDraft && (
<>
<header className="draft-detail__header">
<p className="workspace-route-pending__eyebrow">Draft authoring workbench</p>
<h1>{titleFor(draft)}</h1>
<p className="draft-detail__workspace-id">{draft.workspaceId}</p>
<h1>{titleFor(displayedDraft)}</h1>
<p className="draft-detail__workspace-id">{displayedDraft.workspaceId}</p>
<p className="draft-detail__status-line">
<span className="draft-workspaces__status" data-status={draft.status}>
{formatStatus(draft.status)}
<span className="draft-workspaces__status" data-status={displayedDraft.status}>
{formatStatus(displayedDraft.status)}
</span>
<span>Revision {draft.revision}</span>
<span>Revision {displayedDraft.revision}</span>
</p>
</header>
@@ -68,6 +89,7 @@ export const DraftDetailRoute = ({
draft={draft}
enableNavigationProtection={enableNavigationProtection}
initialSelection={initialSelection}
onDraftChange={handleDraftChange}
/>
</>
)}