diff --git a/frontend/src/components/BulkActionReviewModal.tsx b/frontend/src/components/BulkActionReviewModal.tsx new file mode 100644 index 00000000..2605f55f --- /dev/null +++ b/frontend/src/components/BulkActionReviewModal.tsx @@ -0,0 +1,97 @@ +import { useEffect, useRef } from "react"; + +interface BulkActionReviewModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + actionLabel?: string; + selectedCount?: number; +} + +export default function BulkActionReviewModal({ + isOpen, + onClose, + onConfirm, + actionLabel = "Delete", + selectedCount = 0, +}: BulkActionReviewModalProps) { + const cancelRef = useRef(null); + const modalRef = useRef(null); + + useEffect(() => { + if (isOpen) cancelRef.current?.focus(); + }, [isOpen]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") onClose(); + if (e.key === "Tab") { + const focusable = modalRef.current?.querySelectorAll( + 'button, [href], input, [tabindex]:not([tabindex="-1"])', + ); + if (!focusable || focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }; + + if (!isOpen) return null; + + return ( + + ); +} diff --git a/frontend/src/pages/Scans.tsx b/frontend/src/pages/Scans.tsx index 6b5046ff..fe9c12c9 100644 --- a/frontend/src/pages/Scans.tsx +++ b/frontend/src/pages/Scans.tsx @@ -9,6 +9,7 @@ import { formatLocaleTime, } from "../utils/date"; import Pagination from "../components/Pagination"; +import BulkActionReviewModal from "../components/BulkActionReviewModal"; interface Task { task_id: string; @@ -60,6 +61,7 @@ export default function Scans() { const [filter, setFilter] = useState("all"); const [expandedId, setExpandedId] = useState(null); const [selectedIds, setSelectedIds] = useState([]); + const [showBulkModal, setShowBulkModal] = useState(false); const [page, setPage] = useState(1); const [total, setTotal] = useState(0); const PAGE_LIMIT = 10; @@ -87,7 +89,7 @@ export default function Scans() { if (document.visibilityState === "hidden") { stopPolling(); } else { - loadTasks(); // immediate refresh when tab comes back + loadTasks(); // immediate refresh when tab comes back startPolling(); } } @@ -187,14 +189,11 @@ export default function Scans() { async function handleBulkDelete() { if (selectedIds.length === 0) return; - if ( - !window.confirm( - `Are you sure you want to delete ${selectedIds.length} selected scan records?`, - ) - ) { - return; - } + setShowBulkModal(true); + } + async function confirmBulkDelete() { + setShowBulkModal(false); try { await bulkDeleteTasks(selectedIds); setTasks((prev) => prev.filter((t) => !selectedIds.includes(t.task_id))); @@ -657,6 +656,13 @@ export default function Scans() { ))} + setShowBulkModal(false)} + onConfirm={confirmBulkDelete} + actionLabel="Delete" + selectedCount={selectedIds.length} + /> ); } diff --git a/frontend/testing/unit/components/BulkActionReviewModal.test.tsx b/frontend/testing/unit/components/BulkActionReviewModal.test.tsx new file mode 100644 index 00000000..f4d6b858 --- /dev/null +++ b/frontend/testing/unit/components/BulkActionReviewModal.test.tsx @@ -0,0 +1,120 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import BulkActionReviewModal from "../../../src/components/BulkActionReviewModal"; + +describe("BulkActionReviewModal", () => { + const defaultProps = { + isOpen: true, + onClose: vi.fn(), + onConfirm: vi.fn(), + actionLabel: "Delete", + selectedCount: 3, + }; + + it("renders modal when isOpen is true", () => { + render(); + expect(screen.getByRole("dialog", { hidden: true })).toBeInTheDocument(); + }); + + it("does not render when isOpen is false", () => { + render(); + expect( + screen.queryByRole("dialog", { hidden: true }), + ).not.toBeInTheDocument(); + }); + + it("shows correct selected count", () => { + render(); + expect(screen.getByText(/5 items/i)).toBeInTheDocument(); + }); + + it("calls onConfirm when confirm button clicked", () => { + const onConfirm = vi.fn(); + render(); + fireEvent.click(screen.getByText(/Yes, Delete/i)); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when cancel button clicked", () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByText(/Cancel/i)); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when Escape key is pressed", () => { + const onClose = vi.fn(); + render(); + fireEvent.keyDown(screen.getByRole("dialog", { hidden: true }), { + key: "Escape", + }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("shows singular item text for count of 1", () => { + render(); + const desc = screen.getByText((_, element) => { + return ( + (element?.id === "bulk-action-desc" && + element.textContent?.includes("1 item") && + !element.textContent?.includes("1 items")) || + false + ); + }); + expect(desc).toBeInTheDocument(); + }); + + it("does NOT call onConfirm when cancel is clicked (no deletion before confirmation)", () => { + const onConfirm = vi.fn(); + const onClose = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText(/Cancel/i)); + expect(onConfirm).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does NOT call onConfirm when Escape is pressed (no deletion before confirmation)", () => { + const onConfirm = vi.fn(); + const onClose = vi.fn(); + render( + , + ); + fireEvent.keyDown(screen.getByRole("dialog", { hidden: true }), { + key: "Escape", + }); + expect(onConfirm).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("deletion only happens after confirm button is clicked end-to-end", () => { + const onConfirm = vi.fn(); + const onClose = vi.fn(); + render( + , + ); + expect(onConfirm).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText(/Yes, Delete/i)); + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("focuses cancel button on open for safe keyboard navigation", () => { + render(); + const cancelBtn = screen.getByText("Cancel"); + expect(cancelBtn).toBeInTheDocument(); + }); +}); diff --git a/frontend/testing/unit/pages/Scans.test.tsx b/frontend/testing/unit/pages/Scans.test.tsx new file mode 100644 index 00000000..2964445b --- /dev/null +++ b/frontend/testing/unit/pages/Scans.test.tsx @@ -0,0 +1,93 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { MemoryRouter } from "react-router-dom"; + +vi.mock("../../../src/api", () => ({ + API_BASE: "http://localhost:8000", + deleteTask: vi.fn(), + clearAllTasks: vi.fn(), + bulkDeleteTasks: vi.fn().mockResolvedValue({}), +})); + +global.fetch = vi.fn().mockResolvedValue({ + json: () => + Promise.resolve({ + tasks: [ + { + task_id: "1", + tool: "nmap", + target: "localhost", + status: "completed", + created_at: new Date().toISOString(), + plugin_id: "nmap", + }, + { + task_id: "2", + tool: "nikto", + target: "localhost", + status: "completed", + created_at: new Date().toISOString(), + plugin_id: "nikto", + }, + ], + pagination: { total_items: 2 }, + }), +}); + +import Scans from "../../../src/pages/Scans"; +import { bulkDeleteTasks } from "../../../src/api"; + +const renderScans = () => + render( + + + , + ); + +describe("Scans bulk delete end-to-end flow", () => { + beforeEach(() => { + vi.clearAllMocks(); + global.fetch = vi.fn().mockResolvedValue({ + json: () => + Promise.resolve({ + tasks: [ + { + task_id: "1", + tool: "nmap", + target: "localhost", + status: "completed", + created_at: new Date().toISOString(), + plugin_id: "nmap", + }, + { + task_id: "2", + tool: "nikto", + target: "localhost", + status: "completed", + created_at: new Date().toISOString(), + plugin_id: "nikto", + }, + ], + pagination: { total_items: 2 }, + }), + }); + }); + + it("does NOT call bulkDeleteTasks before confirmation", async () => { + renderScans(); + expect(bulkDeleteTasks).not.toHaveBeenCalled(); + }); + + it("modal is not visible on initial render", async () => { + renderScans(); + expect( + screen.queryByRole("dialog", { hidden: true }), + ).not.toBeInTheDocument(); + }); + + it("no deletion happens without user confirmation", async () => { + renderScans(); + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + expect(bulkDeleteTasks).not.toHaveBeenCalled(); + }); +});