molecule-core/canvas/src/components/BatchActionBar.tsx
molecule-ai[bot] 45cf87c7b8 test(BatchActionBar): add hasFailedBatch success-reset test (#1170)
CP-QA approved. 34-line test for BatchActionBar retry state reset after successful batch action.
2026-04-21 00:12:37 +00:00

151 lines
6.4 KiB
TypeScript

"use client";
import { useEffect, 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);
// Retry survivorship (QA pr-949 follow-up): when a batch action partial-fails
// and leaves a single survivor id, the default `count < 2` gate unmounts the
// bar and forces per-node context-menu retry. Track "active failure" so the
// bar stays mounted with a single item and the user can click the same action
// button to retry without re-selecting. Resets on success or Escape/clear.
const [hasFailedBatch, setHasFailedBatch] = useState(false);
const count = selectedNodeIds.size;
// Reset failure flag when the user clears selection (Escape / ✕ button).
useEffect(() => {
if (count === 0 && hasFailedBatch) setHasFailedBatch(false);
}, [count, hasFailedBatch]);
// Hide when nothing is selected. Hide for single-node selection UNLESS a
// partial-failure left a survivor awaiting retry.
if (count === 0) return null;
if (count < 2 && !hasFailedBatch) return null;
// Message copy must handle both multi (count >= 2) and single-survivor retry
// (count === 1 && hasFailedBatch). Use a helper so we render singular form
// only when there is exactly one survivor to act on.
const plural = (n: number) => (n === 1 ? "workspace" : "workspaces");
const confirmMessages: Record<NonNullable<BatchAction>, string> = {
restart: `Restart ${count} ${plural(count)}? Each will briefly go offline while it restarts.`,
pause: `Pause ${count} ${plural(count)}? Their containers will be stopped.`,
delete: `Permanently delete ${count} ${plural(count)}? 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();
// Reaching here means every store call fulfilled (the store throws on
// any partial failure), so `count` is the actual success count.
showToast(`${pending.charAt(0).toUpperCase() + pending.slice(1)} applied to ${count} ${plural(count)}`, "success");
setHasFailedBatch(false);
clearSelection();
} catch (e) {
const msg = e instanceof Error && e.message ? e.message : `Batch ${pending} failed`;
showToast(msg, "error");
// Leave the failed IDs selected (the store preserved them) so the user
// can retry without re-selecting, and set hasFailedBatch so the bar
// stays mounted even if a single survivor remains.
setHasFailedBatch(true);
} 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)}
/>
</>
);
}