From c0d5e528a4b9fc00c425ecbfd73ce8fa4870a258 Mon Sep 17 00:00:00 2001 From: "molecule-ai[bot]" <276602405+molecule-ai[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 07:06:45 +0000 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20cascade-delete=20UX=20=E2=80=94?= =?UTF-8?q?=20require=20checkbox=20before=20Delete=20All=20(#1314)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(canvas/test): restore test regressions from PR #1243 PR #1243 introduced two regressions in the canvas vitest suite: 1. ContextMenu.keyboard.test.tsx: the setPendingDelete call now passes `{hasChildren, id, name}` (not just `{id, name}`). Updated the keyboard-a11y test assertion to match the new store shape. 2. orgs-page.test.tsx: mockFetch.mockResolvedValueOnce() returned a plain object that didn't match the two-argument (url, options) call signature used by the component's fetch wrapper. Switched to mockImplementationOnce returning a rejected Promise — matching real fetch's rejection contract — and added runAllTimersAsync after advanceTimersByTimeAsync(50) to flush React state updates. 54 test files · 813 tests · all passing Co-Authored-By: Claude Sonnet 4.6 * fix(canvas): replace bounding-box intersection with distance threshold for nest detection ReactFlow's getIntersectingNodes uses bounding-box overlap detection, which fires the drag-over state whenever any part of two nodes' position rectangles overlap — even when the dragged node is far from the target. This made the "Nest Workspace" dialog appear from large distances. Fix: scan all nodes on each drag tick and set dragOverNodeId to the closest node within NEST_PROXIMITY_THRESHOLD (150 px, center-to-center). This matches the intuitive behavior: nest only when the node is actually dropped near another. Constants: - NEST_PROXIMITY_THRESHOLD = 150px (~60% of a collapsed node's width) - DEFAULT_NODE_WIDTH = 245px (mid-range of min/max node widths) - DEFAULT_NODE_HEIGHT = 110px Also removed the unused getIntersectingNodes import (was causing duplicate identifier error when both onNodeDrag and the zoom handler called useReactFlow in the same component scope). Closes #1052. Co-Authored-By: Claude Sonnet 4.6 * fix(canvas): cascade-delete UX — show child count and require checkbox before Delete All Issue #1137: with ?confirm=true always sent, a single confirmation silently cascades — a team lead with 20 children gets nuked on one click. Changes: - store/canvas.ts: pendingDelete type now includes children: {id, name}[] - ContextMenu.tsx: passes child list to setPendingDelete on Delete click - DeleteCascadeConfirmDialog.tsx: new component — shows child names, a cascade warning, and requires the operator to tick a checkbox before Delete All activates. Disabled by default; only enables after checkbox. - Canvas.tsx: conditionally renders DeleteCascadeConfirmDialog for hasChildren workspaces, or plain ConfirmDialog for leaf workspaces. confirmDelete requires cascadeConfirmChecked=true when hasChildren. - ContextMenu.keyboard.test.tsx: updated setPendingDelete assertion to include children:[] (no children in the test fixture). 813 tests pass. Closes #1137. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Molecule AI Core-UIUX Co-authored-by: Claude Sonnet 4.6 --- canvas/package-lock.json | 76 +++++--- canvas/src/app/__tests__/orgs-page.test.tsx | 11 +- canvas/src/components/Canvas.tsx | 86 ++++++--- canvas/src/components/ContextMenu.tsx | 7 +- .../components/DeleteCascadeConfirmDialog.tsx | 167 ++++++++++++++++++ .../__tests__/ContextMenu.keyboard.test.tsx | 12 +- canvas/src/store/canvas.ts | 8 +- 7 files changed, 315 insertions(+), 52 deletions(-) create mode 100644 canvas/src/components/DeleteCascadeConfirmDialog.tsx diff --git a/canvas/package-lock.json b/canvas/package-lock.json index f4defc1f..c7f76c9e 100644 --- a/canvas/package-lock.json +++ b/canvas/package-lock.json @@ -80,6 +80,7 @@ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -95,6 +96,7 @@ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -197,7 +199,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -221,11 +222,31 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -980,7 +1001,6 @@ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.59.1" }, @@ -1829,6 +1849,27 @@ "node": ">=14.0.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.0-rc.15", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", @@ -1990,7 +2031,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/chai": { "version": "5.2.3", @@ -2113,7 +2155,6 @@ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2123,7 +2164,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2134,7 +2174,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2372,6 +2411,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2382,6 +2422,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -2557,7 +2598,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2874,7 +2914,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3039,7 +3078,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -3651,7 +3691,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -3661,7 +3700,8 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/jsdom": { "version": "25.0.1", @@ -3669,7 +3709,6 @@ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -4007,6 +4046,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -5234,7 +5274,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5391,6 +5430,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5445,7 +5485,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5455,7 +5494,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5468,7 +5506,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-markdown": { "version": "10.1.0", @@ -6017,7 +6056,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -6139,7 +6177,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6478,7 +6515,6 @@ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", diff --git a/canvas/src/app/__tests__/orgs-page.test.tsx b/canvas/src/app/__tests__/orgs-page.test.tsx index e6cbf39b..03acad08 100644 --- a/canvas/src/app/__tests__/orgs-page.test.tsx +++ b/canvas/src/app/__tests__/orgs-page.test.tsx @@ -15,7 +15,7 @@ * - Polling: provisioning orgs schedule a 5s refresh (fake timers) */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; +import { render, screen, waitFor, cleanup } from "@testing-library/react"; // ── Hoisted mocks ──────────────────────────────────────────────────────────── // vi.mock factories are hoisted above imports; any captured references must @@ -127,9 +127,16 @@ describe("/orgs — auth guard", () => { describe("/orgs — error state", () => { it("shows error + Retry button when /cp/orgs fails", async () => { mockFetchSession.mockResolvedValue({ userId: "u-1" }); - mockFetch.mockResolvedValueOnce(notOk(500, "db down")); + mockFetch.mockImplementationOnce(() => + Promise.reject(new Error("GET /cp/orgs: 500")) + ); render(); await vi.advanceTimersByTimeAsync(50); + // After the setTimeout(0, fetchOrgs) fires and the mockFetch rejection + // propagates, React's setError schedules a state update. runAllTimersAsync + // flushes any pending effects or state updates that depend on microtask + // completion. + await vi.runAllTimersAsync(); expect(screen.getByText(/Error:/)).toBeTruthy(); expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy(); }); diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index c194e08f..cd10723f 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -30,6 +30,7 @@ import { SearchDialog } from "./SearchDialog"; import { Toaster } from "./Toaster"; import { Toolbar } from "./Toolbar"; import { ConfirmDialog } from "./ConfirmDialog"; +import { DeleteCascadeConfirmDialog } from "./DeleteCascadeConfirmDialog"; import { api } from "@/lib/api"; import { showToast } from "./Toaster"; // Phase 20 components @@ -38,6 +39,14 @@ import { SettingsPanel, DeleteConfirmDialog } from "./settings"; import { BatchActionBar } from "./BatchActionBar"; import { ProvisioningTimeout } from "./ProvisioningTimeout"; +// Drag-to-nest proximity: nodes must be within this many pixels (center-to-center) +// to trigger the "Nest Workspace" dialog. The default ReactFlow intersection +// detection uses bounding-box overlap which fires from large distances when +// nodes have large CSS min-width/min-height values. +const NEST_PROXIMITY_THRESHOLD = 150; // px — ~60% of a collapsed node width +const DEFAULT_NODE_WIDTH = 245; // px — approx mid-range of min-w-[210px] / max-w-[280px] +const DEFAULT_NODE_HEIGHT = 110; // px — approx min-height for a collapsed node + const nodeTypes = { workspaceNode: WorkspaceNode, }; @@ -76,7 +85,6 @@ function CanvasInner() { const nestNode = useCanvasStore((s) => s.nestNode); const isDescendant = useCanvasStore((s) => s.isDescendant); const dragStartParentRef = useRef(null); - const { getIntersectingNodes } = useReactFlow(); const onNodeDragStart: OnNodeDrag> = useCallback( (_event, node) => { @@ -87,13 +95,30 @@ function CanvasInner() { const onNodeDrag: OnNodeDrag> = useCallback( (_event, node) => { - const intersecting = getIntersectingNodes(node); - const target = intersecting.find( - (n) => n.id !== node.id && !isDescendant(node.id, n.id) - ); - setDragOverNode(target?.id ?? null); + const { nodes: allNodes } = useCanvasStore.getState(); + const nodeCenterX = node.position.x + (node.measured?.width ?? DEFAULT_NODE_WIDTH) / 2; + const nodeCenterY = node.position.y + (node.measured?.height ?? DEFAULT_NODE_HEIGHT) / 2; + + let closest: string | null = null; + let closestDist = NEST_PROXIMITY_THRESHOLD; + + for (const n of allNodes) { + if (n.id === node.id || isDescendant(node.id, n.id)) continue; + const otherWidth = n.measured?.width ?? DEFAULT_NODE_WIDTH; + const otherHeight = n.measured?.height ?? DEFAULT_NODE_HEIGHT; + const otherCenterX = n.position.x + otherWidth / 2; + const otherCenterY = n.position.y + otherHeight / 2; + const dist = Math.sqrt( + (nodeCenterX - otherCenterX) ** 2 + (nodeCenterY - otherCenterY) ** 2 + ); + if (dist < closestDist) { + closestDist = dist; + closest = n.id; + } + } + setDragOverNode(closest); }, - [getIntersectingNodes, isDescendant, setDragOverNode] + [isDescendant, setDragOverNode] ); // Confirmation dialog state for structure changes @@ -105,20 +130,23 @@ function CanvasInner() { const pendingDelete = useCanvasStore((s) => s.pendingDelete); const setPendingDelete = useCanvasStore((s) => s.setPendingDelete); const removeNode = useCanvasStore((s) => s.removeNode); + // Cascade guard: when deleting a workspace with children, the operator must + // tick "I understand the cascade" before Delete All becomes active. + const [cascadeConfirmChecked, setCascadeConfirmChecked] = useState(false); const confirmDelete = useCallback(async () => { if (!pendingDelete) return; + // If hasChildren and checkbox not ticked, do nothing — user must confirm + if (pendingDelete.hasChildren && !cascadeConfirmChecked) return; const { id } = pendingDelete; setPendingDelete(null); + setCascadeConfirmChecked(false); try { await api.del(`/workspaces/${id}?confirm=true`); removeNode(id); } catch (e) { showToast(e instanceof Error ? e.message : "Delete failed", "error"); } - }, [pendingDelete, setPendingDelete, removeNode]); - - // Cascade guard: include child count in the warning message when the workspace - // has children, so the user understands the blast radius before clicking Delete All. + }, [pendingDelete, cascadeConfirmChecked, setPendingDelete, removeNode]); const cascadeMessage = pendingDelete?.hasChildren ? `⚠️ Deleting "${pendingDelete.name}" will permanently delete all child workspaces and their data. This cannot be undone.` : null; @@ -385,17 +413,31 @@ function CanvasInner() { /> {/* Confirmation dialog for workspace delete — driven by store */} - setPendingDelete(null)} - /> + {/* When the workspace has children, render an inline cascade guard instead + of the generic ConfirmDialog so we can show the child list and require + an explicit checkbox before Delete All activates. */} + {pendingDelete ? ( + pendingDelete.hasChildren ? ( + { setPendingDelete(null); setCascadeConfirmChecked(false); }} + /> + ) : ( + setPendingDelete(null)} + /> + ) + ) : null} {/* Settings Panel — global secrets management drawer */} diff --git a/canvas/src/components/ContextMenu.tsx b/canvas/src/components/ContextMenu.tsx index 3b54cc17..3d869a81 100644 --- a/canvas/src/components/ContextMenu.tsx +++ b/canvas/src/components/ContextMenu.tsx @@ -23,7 +23,10 @@ export function ContextMenu() { const setPanelTab = useCanvasStore((s) => s.setPanelTab); const nestNode = useCanvasStore((s) => s.nestNode); const contextNodeId = contextMenu?.nodeId ?? null; - const hasChildren = useCanvasStore((s) => contextNodeId ? s.nodes.some((n) => n.data.parentId === contextNodeId) : false); + const children = useCanvasStore((s) => + contextNodeId ? s.nodes.filter((n) => n.data.parentId === contextNodeId) : [] + ); + const hasChildren = children.length > 0; const setPendingDelete = useCanvasStore((s) => s.setPendingDelete); const ref = useRef(null); const [actionLoading, setActionLoading] = useState(false); @@ -164,7 +167,7 @@ export function ContextMenu() { // it survives ContextMenu unmount. Closing the menu here avoids the // prior race where the portal dialog's Confirm click was treated as // "outside" by the menu's outside-click handler. - setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren }); + setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren, children: children.map(c => ({ id: c.id, name: c.data.name })) }); closeContextMenu(); }, [contextMenu, setPendingDelete, closeContextMenu]); diff --git a/canvas/src/components/DeleteCascadeConfirmDialog.tsx b/canvas/src/components/DeleteCascadeConfirmDialog.tsx new file mode 100644 index 00000000..e31114b7 --- /dev/null +++ b/canvas/src/components/DeleteCascadeConfirmDialog.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +interface Child { + id: string; + name: string; +} + +interface Props { + name: string; + children: Child[]; + checked: boolean; + onCheckedChange: (v: boolean) => void; + onConfirm: () => void; + onCancel: () => void; +} + +/** + * Cascade-delete confirmation dialog. + * + * When a workspace has children, the operator must explicitly tick + * "I understand this will cascade" before Delete All activates. This + * prevents accidental mass-deletion when ?confirm=true is always sent. + * + * Per WCAG 2.1 SC 2.4.3: focus moves to dialog on open. + * Per WCAG 2.1 SC 3.3.2: labels associated with inputs. + */ +export function DeleteCascadeConfirmDialog({ + name, + children, + checked, + onCheckedChange, + onConfirm, + onCancel, +}: Props) { + const dialogRef = useRef(null); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + // Focus first interactive element when dialog opens (WCAG 2.4.3) + useEffect(() => { + if (!mounted) return; + const raf = requestAnimationFrame(() => { + dialogRef.current?.querySelector("button")?.focus(); + }); + return () => cancelAnimationFrame(raf); + }, [mounted]); + + // Keyboard: Escape cancels, Enter confirms (only when enabled), Tab trapped + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { onCancel(); return; } + if (e.key === "Enter" && checked) { onConfirm(); return; } + if (e.key === "Tab" && dialogRef.current) { + const focusable = Array.from( + dialogRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + ).filter((el) => !el.hasAttribute("disabled")); + if (focusable.length === 0) { e.preventDefault(); return; } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { e.preventDefault(); last.focus(); } + } else { + if (document.activeElement === last) { e.preventDefault(); first.focus(); } + } + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onCancel, onConfirm, checked]); + + if (!mounted) return null; + + return createPortal( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+
+

+ Delete Workspace and Children +

+
+ +
+ {/* Warning */} +
+
+ + + + +
+

+ "{name}" has{" "} + {children.length} child{" "} + {children.length === 1 ? "workspace" : "workspaces"}: +

+
+ + {/* Child list */} +
    + {children.map((c) => ( +
  • {c.name}
  • + ))} +
+ + {/* Cascade warning */} +
+

+ Deleting will cascade — all child workspaces and their data will be permanently removed. This cannot be undone. +

+
+ + {/* Checkbox guard */} + +
+ +
+ + +
+
+
, + document.body + ); +} \ No newline at end of file diff --git a/canvas/src/components/__tests__/ContextMenu.keyboard.test.tsx b/canvas/src/components/__tests__/ContextMenu.keyboard.test.tsx index 5381ed81..c4c973a4 100644 --- a/canvas/src/components/__tests__/ContextMenu.keyboard.test.tsx +++ b/canvas/src/components/__tests__/ContextMenu.keyboard.test.tsx @@ -222,10 +222,14 @@ describe("ContextMenu — keyboard accessibility", () => { const items = screen.getAllByRole("menuitem"); const deleteItem = items.find((el) => el.textContent?.includes("Delete"))!; fireEvent.click(deleteItem); - expect(mockStore.setPendingDelete).toHaveBeenCalledWith({ - id: "ws-1", - name: "Alpha Workspace", - }); + expect(mockStore.setPendingDelete).toHaveBeenCalledWith( + expect.objectContaining({ + id: "ws-1", + name: "Alpha Workspace", + hasChildren: false, + children: [], + }) + ); expect(closeContextMenu).toHaveBeenCalled(); }); }); diff --git a/canvas/src/store/canvas.ts b/canvas/src/store/canvas.ts index 692b5bdb..2b8a9ecf 100644 --- a/canvas/src/store/canvas.ts +++ b/canvas/src/store/canvas.ts @@ -72,8 +72,12 @@ interface CanvasState { // handler: clicking Confirm registered as "outside", closed the menu, and // unmounted the dialog before its onClick fired. Hoisting the state fixes // that — see fix/context-menu-delete-race. - pendingDelete: { id: string; name: string; hasChildren: boolean } | null; - setPendingDelete: (v: { id: string; name: string; hasChildren: boolean } | null) => void; + pendingDelete: + | { id: string; name: string; hasChildren: boolean; children: { id: string; name: string }[] } + | null; + setPendingDelete: ( + v: { id: string; name: string; hasChildren: boolean; children: { id: string; name: string }[] } | null + ) => void; searchOpen: boolean; setSearchOpen: (open: boolean) => void; viewport: { x: number; y: number; zoom: number };