Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 10 additions & 21 deletions client/src/pages/AdminErrors.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { formatDate, formatDateTime } from "@/lib/date-format";
import { useQuery, useMutation } from "@tanstack/react-query";
import { buildBatchDeletePayload } from "./adminErrorsUtils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -265,25 +266,13 @@ export default function AdminErrors() {
}, [finalizePrevious, batchDeleteMutation]);

const handleBatchDelete = useCallback(() => {
if (selectAll) {
const filters: { level?: string; source?: string } = {};
if (levelFilter !== "all") filters.level = levelFilter;
if (sourceFilter !== "all") filters.source = sourceFilter;
if (!filters.level && !filters.source) {
toast({ title: "Filter required", description: "Apply a level or source filter before deleting all entries", variant: "destructive" });
return;
}
const excluded = Array.from(excludedIds);
finalizePrevious();
batchDeleteMutation.mutate({
filters,
...(excluded.length > 0 ? { excludeIds: excluded } : {}),
});
} else {
finalizePrevious();
batchDeleteMutation.mutate({ ids: Array.from(selectedIds) });
}
}, [finalizePrevious, batchDeleteMutation, selectAll, selectedIds, excludedIds, levelFilter, sourceFilter, toast]);
const payload = buildBatchDeletePayload({
selectAll, selectedIds, excludedIds, levelFilter, sourceFilter, logs,
});
if (!payload) return;
finalizePrevious();
batchDeleteMutation.mutate(payload);
}, [finalizePrevious, batchDeleteMutation, selectAll, selectedIds, excludedIds, levelFilter, sourceFilter, logs]);

const selectionCount = selectAll ? logs.length - excludedIds.size : selectedIds.size;
const hasSelection = selectionCount > 0;
Expand Down Expand Up @@ -670,7 +659,7 @@ export default function AdminErrors() {
aria-label="Select all entries"
/>
<span className="text-sm text-muted-foreground">
{selectAll ? "All matching entries selected" : "Select all"}
{selectAll ? `All ${logs.length} visible entries selected` : "Select all"}
</span>
</div>
<div className="space-y-2">
Expand Down Expand Up @@ -772,7 +761,7 @@ export default function AdminErrors() {
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50" data-testid="floating-action-bar">
<div className="flex items-center gap-3 bg-background border rounded-lg shadow-lg px-4 py-3">
<span className="text-sm font-medium" data-testid="text-selection-count">
{selectAll ? `All matching entries` : `${selectionCount} selected`}
{selectAll ? `${selectionCount} visible entries` : `${selectionCount} selected`}
</span>
<Button
variant="destructive"
Expand Down
116 changes: 116 additions & 0 deletions client/src/pages/adminErrorsUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, it, expect } from "vitest";
import { buildBatchDeletePayload } from "./adminErrorsUtils";

const defaults = {
selectAll: false,
selectedIds: new Set<number>(),
excludedIds: new Set<number>(),
levelFilter: "all",
sourceFilter: "all",
logs: [{ id: 1 }, { id: 2 }, { id: 3 }],
};

describe("buildBatchDeletePayload", () => {
// --- individual selection (selectAll = false) ---

it("returns ids when individual entries are selected", () => {
const result = buildBatchDeletePayload({
...defaults,
selectedIds: new Set([1, 3]),
});
expect(result).toEqual({ ids: [1, 3] });
});

it("returns null when no individual entries are selected", () => {
const result = buildBatchDeletePayload(defaults);
expect(result).toBeNull();
});

// --- selectAll with filters ---

it("returns filter-based payload when level filter is active", () => {
const result = buildBatchDeletePayload({
...defaults,
selectAll: true,
levelFilter: "error",
});
expect(result).toEqual({ filters: { level: "error" } });
});

it("returns filter-based payload when source filter is active", () => {
const result = buildBatchDeletePayload({
...defaults,
selectAll: true,
sourceFilter: "scheduler",
});
expect(result).toEqual({ filters: { source: "scheduler" } });
});

it("returns filter-based payload with both filters", () => {
const result = buildBatchDeletePayload({
...defaults,
selectAll: true,
levelFilter: "warning",
sourceFilter: "api",
});
expect(result).toEqual({ filters: { level: "warning", source: "api" } });
});

it("includes excludeIds when filters are active and entries are excluded", () => {
const result = buildBatchDeletePayload({
...defaults,
selectAll: true,
levelFilter: "error",
excludedIds: new Set([2]),
});
expect(result).toEqual({ filters: { level: "error" }, excludeIds: [2] });
});

it("omits excludeIds when the set is empty", () => {
const result = buildBatchDeletePayload({
...defaults,
selectAll: true,
levelFilter: "error",
excludedIds: new Set(),
});
expect(result).toEqual({ filters: { level: "error" } });
expect(result).not.toHaveProperty("excludeIds");
});

// --- selectAll without filters (the bug fix) ---

it("falls back to visible log ids when no filters are active", () => {
const result = buildBatchDeletePayload({
...defaults,
selectAll: true,
});
expect(result).toEqual({ ids: [1, 2, 3] });
});

it("excludes entries from the ids fallback when excludedIds is set", () => {
const result = buildBatchDeletePayload({
...defaults,
selectAll: true,
excludedIds: new Set([2]),
});
expect(result).toEqual({ ids: [1, 3] });
});

it("returns null when selectAll is true but all entries are excluded", () => {
const result = buildBatchDeletePayload({
...defaults,
selectAll: true,
excludedIds: new Set([1, 2, 3]),
});
expect(result).toBeNull();
});

it("returns null when selectAll is true with no filters and logs are empty", () => {
const result = buildBatchDeletePayload({
...defaults,
selectAll: true,
logs: [],
});
expect(result).toBeNull();
});
});
46 changes: 46 additions & 0 deletions client/src/pages/adminErrorsUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Pure utility for computing the batch-delete payload for admin error logs.
* Extracted so the branching logic can be unit-tested without React.
*/

export type BatchDeletePayload =
| { ids: number[] }
| { filters: { level?: string; source?: string }; excludeIds?: number[] };

/**
* Decides which payload shape to send to POST /api/admin/error-logs/batch-delete.
*
* @returns the payload, or `null` when there is nothing to delete.
*/
export function buildBatchDeletePayload(opts: {
selectAll: boolean;
selectedIds: Set<number>;
excludedIds: Set<number>;
levelFilter: string;
sourceFilter: string;
logs: { id: number }[];
}): BatchDeletePayload | null {
const { selectAll, selectedIds, excludedIds, levelFilter, sourceFilter, logs } = opts;

if (!selectAll) {
const ids = Array.from(selectedIds);
return ids.length > 0 ? { ids } : null;
}

// selectAll path — prefer filter-based delete when a filter is active
const filters: { level?: string; source?: string } = {};
if (levelFilter !== "all") filters.level = levelFilter;
if (sourceFilter !== "all") filters.source = sourceFilter;

if (filters.level || filters.source) {
const excluded = Array.from(excludedIds);
return {
filters,
...(excluded.length > 0 ? { excludeIds: excluded } : {}),
};
}

// No filters — fall back to sending visible IDs directly
const ids = logs.filter(l => !excludedIds.has(l.id)).map(l => l.id);
return ids.length > 0 ? { ids } : null;
}
Loading