fix: sync draft route freshness
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user