* 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>
167 lines
6.2 KiB
TypeScript
167 lines
6.2 KiB
TypeScript
"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
|
|
);
|
|
} |