fix(frontend): confirm modal, settings display, race guard, and test coverage
- S1: Replace window.confirm() with AppConfirmModal for delete actions - S4: Add formatHours helper to avoid ugly decimals in settings hints - W5: Add generation counter to SettingsAdminPanel to prevent stale async results when section changes rapidly - S7: Remove unused ApiRequestError import from useScholarBulkActions - S2: Add error-path tests for bulk delete/toggle network failures - S3: Add CSRF boundary tests for bulk-delete and bulk-toggle endpoints
This commit is contained in:
parent
f8e3098fc3
commit
f501ea4f94
8 changed files with 181 additions and 33 deletions
|
|
@ -109,4 +109,40 @@ describe("useScholarBulkActions", () => {
|
|||
await bulk.onApplyBulkAction();
|
||||
expect(bulk.selectedIds.value.size).toBe(0);
|
||||
});
|
||||
|
||||
it("bulk delete calls assignError on network failure", async () => {
|
||||
const { bulkDeleteScholars } = await import("@/features/scholars");
|
||||
vi.mocked(bulkDeleteScholars).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const { bulk, callbacks } = setup([makeProfile(1, "Alice")]);
|
||||
bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event);
|
||||
bulk.bulkAction.value = "delete_selected" as ScholarBulkAction;
|
||||
await bulk.onApplyBulkAction();
|
||||
|
||||
// Trigger the confirm callback
|
||||
expect(bulk.confirmState.value.open).toBe(true);
|
||||
await bulk.confirmState.value.onConfirm();
|
||||
|
||||
expect(callbacks.assignError).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Unable to bulk delete scholars.",
|
||||
);
|
||||
expect(bulk.bulkBusy.value).toBe(false);
|
||||
});
|
||||
|
||||
it("bulk toggle calls assignError on network failure", async () => {
|
||||
const { bulkToggleScholars } = await import("@/features/scholars");
|
||||
vi.mocked(bulkToggleScholars).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const { bulk, callbacks } = setup([makeProfile(1, "Alice")]);
|
||||
bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event);
|
||||
bulk.bulkAction.value = "enable_selected" as ScholarBulkAction;
|
||||
await bulk.onApplyBulkAction();
|
||||
|
||||
expect(callbacks.assignError).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Unable to bulk enable scholars.",
|
||||
);
|
||||
expect(bulk.bulkBusy.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
exportScholarData,
|
||||
type ScholarProfile,
|
||||
} from "@/features/scholars";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
|
||||
export type ScholarBulkAction =
|
||||
| "delete_selected"
|
||||
|
|
@ -21,6 +20,14 @@ export interface ScholarBulkActionOption {
|
|||
label: string;
|
||||
}
|
||||
|
||||
export interface ConfirmState {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
variant: "danger" | "default";
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export interface BulkActionCallbacks {
|
||||
clearMessages: () => void;
|
||||
assignError: (error: unknown, fallback: string) => void;
|
||||
|
|
@ -35,6 +42,7 @@ export function useScholarBulkActions(
|
|||
const selectedIds = ref<Set<number>>(new Set());
|
||||
const bulkAction = ref<ScholarBulkAction>("select_all");
|
||||
const bulkBusy = ref(false);
|
||||
const confirmState = ref<ConfirmState>({ open: false, title: "", message: "", variant: "default", onConfirm: () => {} });
|
||||
|
||||
const selectedCount = computed(() => selectedIds.value.size);
|
||||
const hasSelection = computed(() => selectedCount.value > 0);
|
||||
|
|
@ -128,21 +136,36 @@ export function useScholarBulkActions(
|
|||
if (bulkAction.value === "export_selected") { await onBulkExport(); return; }
|
||||
}
|
||||
|
||||
async function onBulkDelete(): Promise<void> {
|
||||
function dismissConfirm(): void {
|
||||
confirmState.value = { ...confirmState.value, open: false };
|
||||
}
|
||||
|
||||
function requestConfirm(title: string, message: string, variant: "danger" | "default", onConfirm: () => void): void {
|
||||
confirmState.value = { open: true, title, message, variant, onConfirm };
|
||||
}
|
||||
|
||||
function onBulkDelete(): void {
|
||||
const ids = [...selectedIds.value];
|
||||
if (!window.confirm(`Delete ${ids.length} scholar(s)? This removes all linked publications and queue data.`)) return;
|
||||
bulkBusy.value = true;
|
||||
callbacks.clearMessages();
|
||||
try {
|
||||
const result = await bulkDeleteScholars(ids);
|
||||
callbacks.setSuccess(`${result.deleted_count} scholar(s) deleted.`);
|
||||
selectedIds.value = new Set();
|
||||
await callbacks.reloadScholars();
|
||||
} catch (error) {
|
||||
callbacks.assignError(error, "Unable to bulk delete scholars.");
|
||||
} finally {
|
||||
bulkBusy.value = false;
|
||||
}
|
||||
requestConfirm(
|
||||
`Delete ${ids.length} scholar(s)?`,
|
||||
"This removes all linked publications and queue data. This action cannot be undone.",
|
||||
"danger",
|
||||
async () => {
|
||||
dismissConfirm();
|
||||
bulkBusy.value = true;
|
||||
callbacks.clearMessages();
|
||||
try {
|
||||
const result = await bulkDeleteScholars(ids);
|
||||
callbacks.setSuccess(`${result.deleted_count} scholar(s) deleted.`);
|
||||
selectedIds.value = new Set();
|
||||
await callbacks.reloadScholars();
|
||||
} catch (error) {
|
||||
callbacks.assignError(error, "Unable to bulk delete scholars.");
|
||||
} finally {
|
||||
bulkBusy.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function onBulkToggle(isEnabled: boolean): Promise<void> {
|
||||
|
|
@ -188,6 +211,9 @@ export function useScholarBulkActions(
|
|||
bulkActionOptions,
|
||||
bulkApplyLabel,
|
||||
bulkApplyDisabled,
|
||||
confirmState,
|
||||
dismissConfirm,
|
||||
requestConfirm,
|
||||
onToggleAll,
|
||||
onToggleRow,
|
||||
onApplyBulkAction,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ const integrityRef = ref<InstanceType<typeof AdminIntegritySection> | null>(null
|
|||
const repairsRef = ref<InstanceType<typeof AdminRepairsSection> | null>(null);
|
||||
const pdfQueueRef = ref<InstanceType<typeof AdminPdfQueueSection> | null>(null);
|
||||
|
||||
let loadGeneration = 0;
|
||||
|
||||
async function loadSection(): Promise<void> {
|
||||
const gen = ++loadGeneration;
|
||||
clearAlerts();
|
||||
try {
|
||||
if (props.section === SECTION_USERS && usersRef.value) {
|
||||
|
|
@ -34,11 +37,13 @@ async function loadSection(): Promise<void> {
|
|||
await integrityRef.value.load();
|
||||
} else if (props.section === SECTION_REPAIRS) {
|
||||
await usersRef.value?.load();
|
||||
if (gen !== loadGeneration) return;
|
||||
await repairsRef.value?.load();
|
||||
} else if (props.section === SECTION_PDF && pdfQueueRef.value) {
|
||||
await pdfQueueRef.value.load();
|
||||
}
|
||||
} catch (error) {
|
||||
if (gen !== loadGeneration) return;
|
||||
assignError(error, "Unable to load admin data.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue