forked from molecule-ai/molecule-core
fix(canvas): cascade-delete UX — require checkbox before Delete All (#1314)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Molecule AI Core-UIUX <core-uiux@agents.moleculesai.app> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ae2ec74889
commit
c0d5e528a4
76
canvas/package-lock.json
generated
76
canvas/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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(<OrgsPage />);
|
||||
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();
|
||||
});
|
||||
|
||||
@ -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<string | null>(null);
|
||||
const { getIntersectingNodes } = useReactFlow();
|
||||
|
||||
const onNodeDragStart: OnNodeDrag<Node<WorkspaceNodeData>> = useCallback(
|
||||
(_event, node) => {
|
||||
@ -87,13 +95,30 @@ function CanvasInner() {
|
||||
|
||||
const onNodeDrag: OnNodeDrag<Node<WorkspaceNodeData>> = 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 */}
|
||||
<ConfirmDialog
|
||||
open={!!pendingDelete}
|
||||
title={pendingDelete?.hasChildren ? "Delete Workspace and Children" : "Delete Workspace"}
|
||||
message={pendingDelete?.hasChildren
|
||||
? `⚠️ Deleting "${pendingDelete?.name}" will permanently delete all of its child workspaces and their data. This cannot be undone.`
|
||||
: `Permanently delete "${pendingDelete?.name}"? This will stop the container and remove all configuration. This action cannot be undone.`}
|
||||
confirmLabel={pendingDelete?.hasChildren ? "Delete All" : "Delete"}
|
||||
confirmVariant="danger"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => 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 ? (
|
||||
<DeleteCascadeConfirmDialog
|
||||
name={pendingDelete.name}
|
||||
children={pendingDelete.children}
|
||||
checked={cascadeConfirmChecked}
|
||||
onCheckedChange={setCascadeConfirmChecked}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => { setPendingDelete(null); setCascadeConfirmChecked(false); }}
|
||||
/>
|
||||
) : (
|
||||
<ConfirmDialog
|
||||
open={true}
|
||||
title="Delete Workspace"
|
||||
message={`Permanently delete "${pendingDelete.name}"? This will stop the container and remove all configuration. This action cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
confirmVariant="danger"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{/* Settings Panel — global secrets management drawer */}
|
||||
<SettingsPanel workspaceId={settingsWorkspaceId} />
|
||||
|
||||
@ -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<HTMLDivElement>(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]);
|
||||
|
||||
|
||||
167
canvas/src/components/DeleteCascadeConfirmDialog.tsx
Normal file
167
canvas/src/components/DeleteCascadeConfirmDialog.tsx
Normal file
@ -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<HTMLDivElement>(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<HTMLElement>("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<HTMLElement>(
|
||||
'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(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cascade-dialog-title"
|
||||
className="relative bg-zinc-900 border border-red-800/60 rounded-xl shadow-2xl shadow-black/50 max-w-[420px] w-full mx-4 overflow-hidden"
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-zinc-800">
|
||||
<h3 id="cascade-dialog-title" className="text-sm font-semibold text-red-400">
|
||||
Delete Workspace and Children
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4">
|
||||
{/* Warning */}
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="mt-0.5 shrink-0 w-8 h-8 rounded-full bg-red-900/30 flex items-center justify-center">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-red-400">
|
||||
<path d="M8 3L14 13H2L8 3Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/>
|
||||
<path d="M8 7v3M8 11.5v.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-[13px] text-zinc-300 leading-relaxed">
|
||||
<span className="font-medium text-red-300">"{name}"</span> has{" "}
|
||||
<strong className="text-zinc-100">{children.length}</strong> child{" "}
|
||||
{children.length === 1 ? "workspace" : "workspaces"}:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Child list */}
|
||||
<ul className="space-y-1.5 mb-4 ml-4 list-disc list-inside text-[12px] text-zinc-400 max-h-32 overflow-y-auto">
|
||||
{children.map((c) => (
|
||||
<li key={c.id} className="truncate" title={c.name}>{c.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Cascade warning */}
|
||||
<div className="rounded border border-red-900/40 bg-red-950/20 px-3 py-2.5 mb-4">
|
||||
<p className="text-[12px] text-red-300/80 leading-relaxed">
|
||||
Deleting will cascade — <strong className="text-red-200">all child workspaces and their data will be permanently removed.</strong> This cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Checkbox guard */}
|
||||
<label className="flex items-start gap-2.5 cursor-pointer group select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onCheckedChange(e.target.checked)}
|
||||
className="mt-0.5 w-4 h-4 rounded border-zinc-600 bg-zinc-800 text-red-500 focus:ring-red-500 focus:ring-offset-0 focus:ring-offset-zinc-900 cursor-pointer"
|
||||
/>
|
||||
<span className="text-[12px] text-zinc-400 group-hover:text-zinc-300 leading-relaxed">
|
||||
I understand this will permanently delete all listed workspaces and their data
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-zinc-800 bg-zinc-950/50">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[13px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={!checked}
|
||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors
|
||||
${checked
|
||||
? "bg-red-600 hover:bg-red-500 text-white cursor-pointer"
|
||||
: "bg-red-900/30 text-red-500/40 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
Delete All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 };
|
||||
|
||||
Loading…
Reference in New Issue
Block a user