Merge pull request #949 from Molecule-AI/feat/canvas-batch-operations
feat(canvas): batch operations — multi-select + restart/pause/delete
This commit is contained in:
commit
a3e70b739c
124
canvas/src/components/BatchActionBar.tsx
Normal file
124
canvas/src/components/BatchActionBar.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { showToast } from "./Toaster";
|
||||
|
||||
type BatchAction = "restart" | "pause" | "delete" | null;
|
||||
|
||||
export function BatchActionBar() {
|
||||
const selectedNodeIds = useCanvasStore((s) => s.selectedNodeIds);
|
||||
const clearSelection = useCanvasStore((s) => s.clearSelection);
|
||||
const batchRestart = useCanvasStore((s) => s.batchRestart);
|
||||
const batchPause = useCanvasStore((s) => s.batchPause);
|
||||
const batchDelete = useCanvasStore((s) => s.batchDelete);
|
||||
|
||||
const [pending, setPending] = useState<BatchAction>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const count = selectedNodeIds.size;
|
||||
if (count < 2) return null;
|
||||
|
||||
const confirmMessages: Record<NonNullable<BatchAction>, string> = {
|
||||
restart: `Restart ${count} workspace${count !== 1 ? "s" : ""}? Each will briefly go offline while it restarts.`,
|
||||
pause: `Pause ${count} workspace${count !== 1 ? "s" : ""}? Their containers will be stopped.`,
|
||||
delete: `Permanently delete ${count} workspace${count !== 1 ? "s" : ""}? This cannot be undone.`,
|
||||
};
|
||||
|
||||
const confirmLabels: Record<NonNullable<BatchAction>, string> = {
|
||||
restart: "Restart All",
|
||||
pause: "Pause All",
|
||||
delete: "Delete All",
|
||||
};
|
||||
|
||||
async function execute() {
|
||||
if (!pending) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (pending === "restart") await batchRestart();
|
||||
if (pending === "pause") await batchPause();
|
||||
if (pending === "delete") await batchDelete();
|
||||
showToast(`${pending.charAt(0).toUpperCase() + pending.slice(1)} applied to ${count} workspace${count !== 1 ? "s" : ""}`, "success");
|
||||
clearSelection();
|
||||
} catch {
|
||||
showToast(`Batch ${pending} failed`, "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
const bar = (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Batch workspace actions"
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[200] flex items-center gap-3 px-4 py-2.5 rounded-2xl bg-zinc-900/95 border border-zinc-700/70 shadow-2xl shadow-black/50 backdrop-blur-md"
|
||||
>
|
||||
{/* Selection count badge */}
|
||||
<span className="text-[12px] font-semibold text-zinc-100 bg-blue-600/80 px-2.5 py-0.5 rounded-full tabular-nums">
|
||||
{count} selected
|
||||
</span>
|
||||
|
||||
<div className="w-px h-5 bg-zinc-700/60" aria-hidden="true" />
|
||||
|
||||
{/* Action buttons */}
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => setPending("restart")}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-sky-300 bg-sky-900/30 hover:bg-sky-800/50 border border-sky-700/30 hover:border-sky-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/70"
|
||||
>
|
||||
<span aria-hidden="true">↻</span>
|
||||
Restart All
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => setPending("pause")}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-amber-300 bg-amber-900/30 hover:bg-amber-800/50 border border-amber-700/30 hover:border-amber-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/70"
|
||||
>
|
||||
<span aria-hidden="true">⏸</span>
|
||||
Pause All
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => setPending("delete")}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-red-300 bg-red-900/30 hover:bg-red-800/50 border border-red-700/30 hover:border-red-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/70"
|
||||
>
|
||||
<span aria-hidden="true">✕</span>
|
||||
Delete All
|
||||
</button>
|
||||
|
||||
<div className="w-px h-5 bg-zinc-700/60" aria-hidden="true" />
|
||||
|
||||
{/* Deselect */}
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={clearSelection}
|
||||
aria-label="Clear selection"
|
||||
title="Clear selection (Escape)"
|
||||
className="p-1.5 rounded-lg text-[12px] text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500/70"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{typeof window !== "undefined" ? createPortal(bar, document.body) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!pending}
|
||||
title={pending ? confirmLabels[pending] : ""}
|
||||
message={pending ? confirmMessages[pending] : ""}
|
||||
confirmLabel={pending ? confirmLabels[pending] : "Confirm"}
|
||||
confirmVariant={pending === "delete" ? "danger" : pending === "pause" ? "warning" : "primary"}
|
||||
onConfirm={execute}
|
||||
onCancel={() => setPending(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -32,6 +32,8 @@ import { Toolbar } from "./Toolbar";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
// Phase 20 components
|
||||
import { SettingsPanel, DeleteConfirmDialog } from "./settings";
|
||||
// Phase 20.3 batch operations
|
||||
import { BatchActionBar } from "./BatchActionBar";
|
||||
import { ProvisioningTimeout } from "./ProvisioningTimeout";
|
||||
|
||||
const nodeTypes = {
|
||||
@ -133,7 +135,9 @@ function CanvasInner() {
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
selectNode(null);
|
||||
useCanvasStore.getState().closeContextMenu();
|
||||
const state = useCanvasStore.getState();
|
||||
state.closeContextMenu();
|
||||
state.clearSelection();
|
||||
}, [selectNode]);
|
||||
|
||||
// Team zoom-in: double-click a team node to zoom to its children
|
||||
@ -192,6 +196,8 @@ function CanvasInner() {
|
||||
const state = useCanvasStore.getState();
|
||||
if (state.contextMenu) {
|
||||
state.closeContextMenu();
|
||||
} else if (state.selectedNodeIds.size > 0) {
|
||||
state.clearSelection();
|
||||
} else if (state.selectedNodeId) {
|
||||
state.selectNode(null);
|
||||
}
|
||||
@ -336,6 +342,7 @@ function CanvasInner() {
|
||||
<Toaster />
|
||||
<ProvisioningTimeout />
|
||||
{!selectedNodeId && <CreateWorkspaceButton />}
|
||||
<BatchActionBar />
|
||||
|
||||
{/* Confirmation dialog for structure changes */}
|
||||
<ConfirmDialog
|
||||
|
||||
@ -47,6 +47,9 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
const nestNode = useCanvasStore((s) => s.nestNode);
|
||||
const isDragTarget = useCanvasStore((s) => s.dragOverNodeId === id);
|
||||
const isSelected = selectedNodeId === id;
|
||||
// Batch selection (Phase 20.3)
|
||||
const isBatchSelected = useCanvasStore((s) => s.selectedNodeIds.has(id));
|
||||
const toggleNodeSelection = useCanvasStore((s) => s.toggleNodeSelection);
|
||||
const isOnline = data.status === "online";
|
||||
|
||||
// Get children + hierarchy info (single stable selector avoids redundant re-renders)
|
||||
@ -68,7 +71,11 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
aria-pressed={isSelected}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
selectNode(isSelected ? null : id);
|
||||
if (e.shiftKey) {
|
||||
toggleNodeSelection(id);
|
||||
} else {
|
||||
selectNode(isSelected ? null : id);
|
||||
}
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@ -84,7 +91,11 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
selectNode(isSelected ? null : id);
|
||||
if (e.shiftKey) {
|
||||
toggleNodeSelection(id);
|
||||
} else {
|
||||
selectNode(isSelected ? null : id);
|
||||
}
|
||||
} else if (e.key === "ContextMenu") {
|
||||
e.preventDefault();
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
@ -103,6 +114,8 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
transition-all duration-200 ease-out
|
||||
${isDragTarget
|
||||
? "bg-emerald-950/40 border-2 border-emerald-400/60 ring-2 ring-emerald-400/20 scale-[1.03]"
|
||||
: isBatchSelected
|
||||
? "bg-zinc-900/95 border-2 border-blue-500/80 ring-2 ring-blue-500/30 shadow-lg shadow-blue-500/15"
|
||||
: isSelected
|
||||
? "bg-zinc-900/95 border border-blue-500/70 ring-1 ring-blue-500/30 shadow-lg shadow-blue-500/10"
|
||||
: "bg-zinc-900/90 border border-zinc-700/80 hover:border-zinc-500/60 shadow-lg shadow-black/30 hover:shadow-xl hover:shadow-black/40"
|
||||
|
||||
127
canvas/src/components/__tests__/BatchActionBar.test.tsx
Normal file
127
canvas/src/components/__tests__/BatchActionBar.test.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* BatchActionBar tests — Phase 20.3
|
||||
*
|
||||
* Covers:
|
||||
* - Not rendered when fewer than 2 nodes selected
|
||||
* - Renders with correct count badge when 2+ selected
|
||||
* - Restart/Pause/Delete buttons exist with correct labels
|
||||
* - Clear selection button exists
|
||||
* - ConfirmDialog appears on destructive action click
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/components/Toaster", () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockClearSelection = vi.fn();
|
||||
const mockBatchRestart = vi.fn(() => Promise.resolve());
|
||||
const mockBatchPause = vi.fn(() => Promise.resolve());
|
||||
const mockBatchDelete = vi.fn(() => Promise.resolve());
|
||||
|
||||
let mockSelectedNodeIds = new Set<string>();
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: vi.fn((selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
selectedNodeIds: mockSelectedNodeIds,
|
||||
clearSelection: mockClearSelection,
|
||||
batchRestart: mockBatchRestart,
|
||||
batchPause: mockBatchPause,
|
||||
batchDelete: mockBatchDelete,
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock ConfirmDialog to just render buttons for testing
|
||||
vi.mock("@/components/ConfirmDialog", () => ({
|
||||
ConfirmDialog: ({
|
||||
open,
|
||||
title,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
confirmLabel: string;
|
||||
message: string;
|
||||
confirmVariant: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-testid="confirm-dialog">
|
||||
<span>{title}</span>
|
||||
<button onClick={onConfirm}>confirm</button>
|
||||
<button onClick={onCancel}>cancel</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
import { BatchActionBar } from "../BatchActionBar";
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("BatchActionBar", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSelectedNodeIds = new Set<string>();
|
||||
});
|
||||
|
||||
it("does not render when fewer than 2 nodes selected", () => {
|
||||
mockSelectedNodeIds = new Set(["ws-1"]);
|
||||
const { container } = render(<BatchActionBar />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders count badge when 2+ nodes selected", () => {
|
||||
mockSelectedNodeIds = new Set(["ws-1", "ws-2", "ws-3"]);
|
||||
render(<BatchActionBar />);
|
||||
expect(screen.getByText("3 selected")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Restart All, Pause All, Delete All buttons", () => {
|
||||
mockSelectedNodeIds = new Set(["ws-1", "ws-2"]);
|
||||
render(<BatchActionBar />);
|
||||
expect(screen.getByText("Restart All")).toBeTruthy();
|
||||
expect(screen.getByText("Pause All")).toBeTruthy();
|
||||
expect(screen.getByText("Delete All")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders clear selection button with aria-label", () => {
|
||||
mockSelectedNodeIds = new Set(["ws-1", "ws-2"]);
|
||||
render(<BatchActionBar />);
|
||||
const clearBtn = screen.getByRole("button", { name: "Clear selection" });
|
||||
expect(clearBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking clear selection calls clearSelection", () => {
|
||||
mockSelectedNodeIds = new Set(["ws-1", "ws-2"]);
|
||||
render(<BatchActionBar />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear selection" }));
|
||||
expect(mockClearSelection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clicking Delete All opens ConfirmDialog", () => {
|
||||
mockSelectedNodeIds = new Set(["ws-1", "ws-2"]);
|
||||
render(<BatchActionBar />);
|
||||
fireEvent.click(screen.getByText("Delete All"));
|
||||
expect(screen.getByTestId("confirm-dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has role=toolbar with aria-label", () => {
|
||||
mockSelectedNodeIds = new Set(["ws-1", "ws-2"]);
|
||||
render(<BatchActionBar />);
|
||||
const toolbar = screen.getByRole("toolbar");
|
||||
expect(toolbar.getAttribute("aria-label")).toBe("Batch workspace actions");
|
||||
});
|
||||
});
|
||||
@ -69,6 +69,9 @@ const mockStoreState = {
|
||||
showA2AEdges: false,
|
||||
setShowA2AEdges: vi.fn(),
|
||||
setPanelTab: vi.fn(),
|
||||
selectedNodeIds: new Set<string>(),
|
||||
clearSelection: vi.fn(),
|
||||
toggleNodeSelection: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
@ -109,6 +112,7 @@ vi.mock("../ProvisioningTimeout", () => ({
|
||||
<div data-testid="provisioning-timeout-sentinel" />
|
||||
),
|
||||
}));
|
||||
vi.mock("../BatchActionBar", () => ({ BatchActionBar: () => null }));
|
||||
|
||||
// ── Import the component under test AFTER mocks ───────────────────────────────
|
||||
import { Canvas } from "../Canvas";
|
||||
|
||||
@ -79,6 +79,9 @@ const mockStoreState = {
|
||||
showA2AEdges: false,
|
||||
setShowA2AEdges: vi.fn(),
|
||||
setPanelTab: vi.fn(),
|
||||
selectedNodeIds: new Set<string>(),
|
||||
clearSelection: vi.fn(),
|
||||
toggleNodeSelection: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
@ -113,6 +116,8 @@ vi.mock("../settings", () => ({
|
||||
}));
|
||||
vi.mock("../Toaster", () => ({ Toaster: () => null }));
|
||||
vi.mock("../WorkspaceNode", () => ({ WorkspaceNode: () => null }));
|
||||
vi.mock("../BatchActionBar", () => ({ BatchActionBar: () => null }));
|
||||
vi.mock("../ProvisioningTimeout", () => ({ ProvisioningTimeout: () => null }));
|
||||
|
||||
import { Canvas } from "../Canvas";
|
||||
|
||||
|
||||
@ -125,6 +125,8 @@ const mockStoreState = {
|
||||
nestNode: mockNestNode,
|
||||
restartWorkspace: vi.fn(() => Promise.resolve()),
|
||||
setPanelTab: vi.fn(),
|
||||
selectedNodeIds: new Set<string>(),
|
||||
toggleNodeSelection: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
|
||||
@ -97,6 +97,8 @@ const mockStoreState = {
|
||||
isDescendant: vi.fn(() => false),
|
||||
restartWorkspace: vi.fn(),
|
||||
setPanelTab: vi.fn(),
|
||||
selectedNodeIds: new Set<string>(),
|
||||
toggleNodeSelection: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
|
||||
@ -71,6 +71,13 @@ interface CanvasState {
|
||||
viewport: { x: number; y: number; zoom: number };
|
||||
setViewport: (v: { x: number; y: number; zoom: number }) => void;
|
||||
saveViewport: (x: number, y: number, zoom: number) => void;
|
||||
// ── Batch selection (Phase 20.3) ─────────────────────────────────────────
|
||||
selectedNodeIds: Set<string>;
|
||||
toggleNodeSelection: (id: string) => void;
|
||||
clearSelection: () => void;
|
||||
batchRestart: () => Promise<void>;
|
||||
batchPause: () => Promise<void>;
|
||||
batchDelete: () => Promise<void>;
|
||||
/** Agent-pushed messages keyed by workspace ID. ChatTab consumes and clears these. */
|
||||
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string }>>;
|
||||
consumeAgentMessages: (workspaceId: string) => Array<{ id: string; content: string; timestamp: string }>;
|
||||
@ -96,6 +103,38 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
panelTab: "chat",
|
||||
dragOverNodeId: null,
|
||||
contextMenu: null,
|
||||
// Batch selection
|
||||
selectedNodeIds: new Set<string>(),
|
||||
toggleNodeSelection: (id) => {
|
||||
const prev = get().selectedNodeIds;
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
set({ selectedNodeIds: next });
|
||||
},
|
||||
clearSelection: () => set({ selectedNodeIds: new Set<string>() }),
|
||||
batchRestart: async () => {
|
||||
const ids = Array.from(get().selectedNodeIds);
|
||||
await Promise.allSettled(ids.map((id) => api.post(`/workspaces/${id}/restart`)));
|
||||
for (const id of ids) {
|
||||
get().updateNodeData(id, { needsRestart: false });
|
||||
}
|
||||
},
|
||||
batchPause: async () => {
|
||||
const ids = Array.from(get().selectedNodeIds);
|
||||
await Promise.allSettled(ids.map((id) => api.post(`/workspaces/${id}/pause`)));
|
||||
},
|
||||
batchDelete: async () => {
|
||||
const ids = Array.from(get().selectedNodeIds);
|
||||
await Promise.allSettled(ids.map((id) => api.del(`/workspaces/${id}`)));
|
||||
for (const id of ids) {
|
||||
get().removeNode(id);
|
||||
}
|
||||
set({ selectedNodeIds: new Set<string>() });
|
||||
},
|
||||
wsStatus: "connecting",
|
||||
setWsStatus: (status) => set({ wsStatus: status }),
|
||||
hydrationError: null,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user