fix: close Task 7 authoring review gaps

This commit is contained in:
lda
2026-08-09 10:28:09 +07:00 Verified
parent abe83a0f24
commit 7d6c47fe77
6 changed files with 126 additions and 14 deletions
@@ -59,6 +59,7 @@ const detail: CapabilityDetail = {
const controller = {
draft,
selection: { kind: "node", nodeId: "read" },
insertionContext: null,
dirty: false,
phase: "idle",
message: null,
@@ -1,4 +1,4 @@
import { cleanup, render, screen, within } from "@testing-library/react";
import { act, cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CapabilityDetail } from "../domain/capability-models.js";
@@ -63,7 +63,10 @@ const setViewport = (width: number): void => {
});
};
afterEach(() => cleanup());
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
beforeEach(() => {
setViewport(1024);
mockedUseAuthoringCapabilityDetail.mockReturnValue({
@@ -195,4 +198,24 @@ describe("DraftWorkbench", () => {
).toHaveValue(" locally edited");
expect(container.querySelector(".draft-workbench")).toHaveAttribute("data-dirty", "true");
});
it("reopens an open desktop sheet as a modal after resizing to mobile", async () => {
setViewport(1024);
const user = userEvent.setup();
const { container } = render(<DraftWorkbench draft={workspace} />);
const inspector = container.querySelector("#draft-workbench-inspector") as HTMLDialogElement;
const showModal = vi.fn(() => inspector.setAttribute("open", ""));
Object.defineProperty(inspector, "showModal", { configurable: true, value: showModal });
Object.defineProperty(inspector, "close", {
configurable: true,
value: vi.fn(() => inspector.removeAttribute("open")),
});
await user.click(screen.getByRole("button", { name: "Open context inspector" }));
setViewport(390);
await act(async () => window.dispatchEvent(new Event("resize")));
expect(showModal).toHaveBeenCalledTimes(1);
expect(inspector).toHaveAttribute("open", "");
});
});
@@ -61,6 +61,7 @@ const MobileSheet = ({
}: MobileSheetProps) => {
const dialogRef = useRef<HTMLDialogElement>(null);
const wasOpen = useRef(open);
const modalRef = useRef(false);
useEffect(() => {
const dialog = dialogRef.current;
@@ -72,6 +73,7 @@ const MobileSheet = ({
} else {
dialog.removeAttribute("open");
}
modalRef.current = false;
};
if (!isMobile) {
@@ -84,9 +86,16 @@ const MobileSheet = ({
if (!dialog.open) {
if (typeof dialog.showModal === "function") {
dialog.showModal();
modalRef.current = true;
} else {
dialog.setAttribute("open", "");
}
} else if (!modalRef.current && typeof dialog.showModal === "function") {
// A desktop sheet is an open nonmodal dialog; close it before moving
// it into the mobile top layer so native inertness and focus trapping apply.
closeDialog();
dialog.showModal();
modalRef.current = true;
}
} else {
closeDialog();
@@ -161,6 +161,26 @@ describe("useDraftAuthoring", () => {
);
});
it("preserves selected connector context when capability selection follows", async () => {
const initial = workspace();
authoringClient.addCapabilityStep.mockResolvedValue(workspace({ revision: 4 }));
const { result, rerender } = renderHook(
({ selection }) => useDraftAuthoring({ draft: initial, initialSelection: selection }),
{
initialProps: {
selection: { kind: "edge", stepId: "read", outcome: "ok" } as WorkbenchSelection,
},
},
);
rerender({ selection: { kind: "capability", qualifiedName: "demo.enrich" } });
await act(async () => result.current.addCapability(capabilityInput));
expect(authoringClient.addCapabilityStep).toHaveBeenCalledWith(
expect.objectContaining({ routeFromStep: "read", routeFromOutcome: "ok" }),
);
});
it("updates capabilities, replaces routes, and validates against the current revision", async () => {
const initial = workspace({ revision: 7 });
authoringClient.updateCapabilityStep.mockResolvedValue(workspace({ revision: 8 }));
@@ -11,13 +11,18 @@ import {
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import type { CapabilityNodeFormValue } from "./CapabilityNodeForm.js";
import type { RouteFormValue } from "./RouteForm.js";
import { deriveInsertionContext, type WorkbenchSelection } from "./authoring-graph.js";
import {
deriveInsertionContext,
type InsertionContext,
type WorkbenchSelection,
} from "./authoring-graph.js";
export type DraftAuthoringPhase = "idle" | "saving" | "conflict" | "error";
export interface DraftAuthoringController {
readonly draft: DraftWorkspace;
readonly selection: WorkbenchSelection;
readonly insertionContext: InsertionContext | null;
readonly dirty: boolean;
readonly phase: DraftAuthoringPhase;
readonly message: string | null;
@@ -48,6 +53,7 @@ type AuthoringState = {
readonly draftInput: DraftWorkspace;
readonly selection: WorkbenchSelection;
readonly selectionInput: WorkbenchSelection;
readonly insertionContext: InsertionContext | null;
readonly dirty: boolean;
readonly phase: DraftAuthoringPhase;
readonly message: string | null;
@@ -134,6 +140,7 @@ export const useDraftAuthoring = ({
draftInput: initialDraft,
selection: initialSelection,
selectionInput: initialSelection,
insertionContext: deriveInsertionContext(initialSelection),
dirty: false,
phase: "idle",
message: null,
@@ -148,6 +155,10 @@ export const useDraftAuthoring = ({
const draft = adoptsDraftInput ? initialDraft : state.draft;
const adoptsSelectionInput = !sameSelection(state.selectionInput, initialSelection);
const selection = adoptsSelectionInput ? initialSelection : state.selection;
const insertionContext =
adoptsSelectionInput && initialSelection.kind === "edge"
? deriveInsertionContext(initialSelection)
: state.insertionContext;
const resetGeneration =
state.resetGeneration + (adoptsDraftInput || adoptsSelectionInput ? 1 : 0);
const currentProvenance: Provenance = useMemo(() => ({
@@ -157,20 +168,26 @@ export const useDraftAuthoring = ({
readExecutor,
}), [connectedTarget, draft.workspaceId, readExecutor, writeExecutor]);
const currentDraftRef = useRef(draft);
const currentSelectionRef = useRef(selection);
const currentInsertionContextRef = useRef(insertionContext);
const currentProvenanceRef = useRef(currentProvenance);
useEffect(() => {
currentDraftRef.current = draft;
currentSelectionRef.current = selection;
currentInsertionContextRef.current = insertionContext;
currentProvenanceRef.current = currentProvenance;
}, [currentProvenance, draft, selection]);
}, [currentProvenance, draft, insertionContext, selection]);
const select = useCallback((selection: WorkbenchSelection): void => {
setState((current) => ({
...current,
selection,
selectionInput: initialSelection,
insertionContext:
selection.kind === "edge"
? deriveInsertionContext(selection)
: selection.kind === "capability"
? current.insertionContext
: null,
}));
}, [initialSelection]);
@@ -197,6 +214,10 @@ export const useDraftAuthoring = ({
response.status === "conflict"
? current.selection
: nextSelection ?? current.selection,
insertionContext:
response.status === "conflict" || nextSelection?.kind !== "node"
? current.insertionContext
: null,
dirty: response.status === "conflict" ? true : false,
phase: response.status === "conflict" ? "conflict" : "idle",
message:
@@ -278,7 +299,7 @@ export const useDraftAuthoring = ({
const addCapability = useCallback(
(input: CapabilityNodeFormValue): Promise<void> => {
lastSubmissionRef.current = { kind: "add", input };
const insertion = deriveInsertionContext(currentSelectionRef.current);
const insertion = currentInsertionContextRef.current;
return runMutation(
"add",
{ input, insertion },
@@ -445,6 +466,7 @@ export const useDraftAuthoring = ({
return {
draft,
selection,
insertionContext,
dirty: state.dirty,
phase: state.phase,
message: state.message,