Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
8b7d987
feat: add accessible bulk-action review modal (fixes #236)
Tanisha-sharma7302 May 24, 2026
e970e2c
style: fix formatting for BulkActionReviewModal
Tanisha-sharma7302 May 24, 2026
5659289
style: remove trailing whitespace from classNames
Tanisha-sharma7302 May 24, 2026
aa112b9
style: trim trailing whitespace
Tanisha-sharma7302 May 24, 2026
29dfbe6
style: convert CRLF to LF line endings
Tanisha-sharma7302 May 24, 2026
149948f
style: recreate file with LF line endings
Tanisha-sharma7302 May 24, 2026
97f45a3
fix: wire BulkActionReviewModal into bulk delete flow and add tests
Tanisha-sharma7302 May 29, 2026
0f5791d
fix: resolve merge conflict and re-apply BulkActionReviewModal wiring
Tanisha-sharma7302 May 29, 2026
f443b74
style: fix formatting issues for hygiene check
Tanisha-sharma7302 May 29, 2026
8607d3a
style: fix trailing whitespace in test file
Tanisha-sharma7302 May 29, 2026
7f0c805
test: add no-deletion-before-confirmation and end-to-end flow tests
Tanisha-sharma7302 May 30, 2026
7b5d792
Merge remote-tracking branch 'upstream/main' into feat/accessible-bul…
Tanisha-sharma7302 May 30, 2026
0cff46d
style: fix formatting in test file
Tanisha-sharma7302 May 30, 2026
2f7b399
test: add Scans end-to-end bulk delete flow tests proving modal wiring
Tanisha-sharma7302 May 31, 2026
260ca68
Merge remote-tracking branch 'upstream/main' into feat/accessible-bul…
Tanisha-sharma7302 May 31, 2026
8c13052
style: fix formatting in Scans test file
Tanisha-sharma7302 May 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions frontend/src/components/BulkActionReviewModal.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>(null);
const modalRef = useRef<HTMLDivElement>(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<HTMLElement>(
'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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
aria-hidden="true"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="bulk-action-title"
aria-describedby="bulk-action-desc"
ref={modalRef}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
className="bg-white dark:bg-gray-900 rounded-lg shadow-xl p-6 w-full max-w-md mx-4"
>
<h2
id="bulk-action-title"
className="text-lg font-semibold text-gray-900 dark:text-white mb-2"
>
Confirm {actionLabel}
</h2>
<p
id="bulk-action-desc"
className="text-sm text-gray-600 dark:text-gray-300 mb-6"
>
You are about to <strong>{actionLabel.toLowerCase()}</strong>{" "}
<strong>
{selectedCount} item{selectedCount !== 1 ? "s" : ""}
</strong>
. This action <strong>cannot be undone</strong>.
</p>
<div className="flex justify-end gap-3">
<button
ref={cancelRef}
onClick={onClose}
className="px-4 py-2 rounded-md border border-gray-300 text-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800 transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
aria-label={`Confirm ${actionLabel} of ${selectedCount} items`}
className="px-4 py-2 rounded-md bg-red-600 text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition-colors"
>
Yes, {actionLabel}
</button>
</div>
</div>
</div>
);
}
22 changes: 14 additions & 8 deletions frontend/src/pages/Scans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
formatLocaleTime,
} from "../utils/date";
import Pagination from "../components/Pagination";
import BulkActionReviewModal from "../components/BulkActionReviewModal";

interface Task {
task_id: string;
Expand Down Expand Up @@ -60,6 +61,7 @@ export default function Scans() {
const [filter, setFilter] = useState("all");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [showBulkModal, setShowBulkModal] = useState(false);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const PAGE_LIMIT = 10;
Expand Down Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -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)));
Expand Down Expand Up @@ -657,6 +656,13 @@ export default function Scans() {
))}
</div>
</footer>
<BulkActionReviewModal
isOpen={showBulkModal}
onClose={() => setShowBulkModal(false)}
onConfirm={confirmBulkDelete}
actionLabel="Delete"
selectedCount={selectedIds.length}
/>
</div>
);
}
120 changes: 120 additions & 0 deletions frontend/testing/unit/components/BulkActionReviewModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<BulkActionReviewModal {...defaultProps} />);
expect(screen.getByRole("dialog", { hidden: true })).toBeInTheDocument();
});

it("does not render when isOpen is false", () => {
render(<BulkActionReviewModal {...defaultProps} isOpen={false} />);
expect(
screen.queryByRole("dialog", { hidden: true }),
).not.toBeInTheDocument();
});

it("shows correct selected count", () => {
render(<BulkActionReviewModal {...defaultProps} selectedCount={5} />);
expect(screen.getByText(/5 items/i)).toBeInTheDocument();
});

it("calls onConfirm when confirm button clicked", () => {
const onConfirm = vi.fn();
render(<BulkActionReviewModal {...defaultProps} onConfirm={onConfirm} />);
fireEvent.click(screen.getByText(/Yes, Delete/i));
expect(onConfirm).toHaveBeenCalledTimes(1);
});

it("calls onClose when cancel button clicked", () => {
const onClose = vi.fn();
render(<BulkActionReviewModal {...defaultProps} onClose={onClose} />);
fireEvent.click(screen.getByText(/Cancel/i));
expect(onClose).toHaveBeenCalledTimes(1);
});

it("calls onClose when Escape key is pressed", () => {
const onClose = vi.fn();
render(<BulkActionReviewModal {...defaultProps} onClose={onClose} />);
fireEvent.keyDown(screen.getByRole("dialog", { hidden: true }), {
key: "Escape",
});
expect(onClose).toHaveBeenCalledTimes(1);
});

it("shows singular item text for count of 1", () => {
render(<BulkActionReviewModal {...defaultProps} selectedCount={1} />);
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(
<BulkActionReviewModal
{...defaultProps}
onConfirm={onConfirm}
onClose={onClose}
/>,
);
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(
<BulkActionReviewModal
{...defaultProps}
onConfirm={onConfirm}
onClose={onClose}
/>,
);
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(
<BulkActionReviewModal
{...defaultProps}
onConfirm={onConfirm}
onClose={onClose}
/>,
);
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(<BulkActionReviewModal {...defaultProps} />);
const cancelBtn = screen.getByText("Cancel");
expect(cancelBtn).toBeInTheDocument();
});
});
93 changes: 93 additions & 0 deletions frontend/testing/unit/pages/Scans.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter>
<Scans />
</MemoryRouter>,
);

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();
});
});
Loading