feat(scholars): add multiselect and bulk actions (delete, toggle, export)
Add Set<number>-based selection state to ScholarsPage with checkboxes in both table and card views, select-all toggle, and stale-key pruning. Backend: POST /scholars/bulk-delete, POST /scholars/bulk-toggle, and GET /scholars/export?ids= filter. Service functions use user-scoped queries. Structured logging for bulk operations. Frontend: useScholarBulkActions composable extracts selection and bulk action logic. AppSelect-based action dropdown with dynamic options. Delete prompts window.confirm; enable/disable applies immediately. Export downloads filtered JSON. Includes integration tests for all bulk endpoints and frontend unit tests for selection toggle, select-all, and stale pruning.
This commit is contained in:
parent
61ac6f206f
commit
6742eff773
9 changed files with 730 additions and 79 deletions
|
|
@ -0,0 +1,112 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { nextTick, ref } from "vue";
|
||||
import { useScholarBulkActions, type ScholarBulkAction } from "./useScholarBulkActions";
|
||||
import type { ScholarProfile } from "@/features/scholars";
|
||||
|
||||
vi.mock("@/features/scholars", () => ({
|
||||
bulkDeleteScholars: vi.fn(),
|
||||
bulkToggleScholars: vi.fn(),
|
||||
exportScholarData: vi.fn(),
|
||||
}));
|
||||
|
||||
function makeProfile(id: number, name: string): ScholarProfile {
|
||||
return {
|
||||
id,
|
||||
scholar_id: `scholar_${id}`,
|
||||
display_name: name,
|
||||
profile_image_url: null,
|
||||
profile_image_source: "none",
|
||||
is_enabled: true,
|
||||
baseline_completed: false,
|
||||
last_run_dt: null,
|
||||
last_run_status: null,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(profiles: ScholarProfile[] = []) {
|
||||
const visibleScholars = ref(profiles);
|
||||
const callbacks = {
|
||||
clearMessages: vi.fn(),
|
||||
assignError: vi.fn(),
|
||||
setSuccess: vi.fn(),
|
||||
reloadScholars: vi.fn(async () => {}),
|
||||
};
|
||||
const bulk = useScholarBulkActions(visibleScholars, callbacks);
|
||||
return { visibleScholars, callbacks, bulk };
|
||||
}
|
||||
|
||||
describe("useScholarBulkActions", () => {
|
||||
it("starts with empty selection", () => {
|
||||
const { bulk } = setup([makeProfile(1, "Alice")]);
|
||||
expect(bulk.selectedIds.value.size).toBe(0);
|
||||
expect(bulk.hasSelection.value).toBe(false);
|
||||
});
|
||||
|
||||
it("toggles individual row selection", () => {
|
||||
const { bulk } = setup([makeProfile(1, "Alice"), makeProfile(2, "Bob")]);
|
||||
bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event);
|
||||
expect(bulk.selectedIds.value.has(1)).toBe(true);
|
||||
expect(bulk.selectedCount.value).toBe(1);
|
||||
|
||||
bulk.onToggleRow(1, { target: { checked: false } } as unknown as Event);
|
||||
expect(bulk.selectedIds.value.has(1)).toBe(false);
|
||||
expect(bulk.selectedCount.value).toBe(0);
|
||||
});
|
||||
|
||||
it("toggles all visible scholars", () => {
|
||||
const { bulk } = setup([makeProfile(1, "Alice"), makeProfile(2, "Bob")]);
|
||||
bulk.onToggleAll({ target: { checked: true } } as unknown as Event);
|
||||
expect(bulk.selectedIds.value.size).toBe(2);
|
||||
expect(bulk.allVisibleSelected.value).toBe(true);
|
||||
|
||||
bulk.onToggleAll({ target: { checked: false } } as unknown as Event);
|
||||
expect(bulk.selectedIds.value.size).toBe(0);
|
||||
});
|
||||
|
||||
it("prunes stale selections when list changes", async () => {
|
||||
const { visibleScholars, bulk } = setup([makeProfile(1, "Alice"), makeProfile(2, "Bob")]);
|
||||
bulk.onToggleAll({ target: { checked: true } } as unknown as Event);
|
||||
expect(bulk.selectedIds.value.size).toBe(2);
|
||||
|
||||
// Remove Bob from the visible list
|
||||
visibleScholars.value = [makeProfile(1, "Alice")];
|
||||
await nextTick();
|
||||
|
||||
expect(bulk.selectedIds.value.size).toBe(1);
|
||||
expect(bulk.selectedIds.value.has(1)).toBe(true);
|
||||
expect(bulk.selectedIds.value.has(2)).toBe(false);
|
||||
});
|
||||
|
||||
it("shows correct bulk action options with and without selection", () => {
|
||||
const { bulk } = setup([makeProfile(1, "Alice")]);
|
||||
// Without selection
|
||||
expect(bulk.bulkActionOptions.value.length).toBe(1);
|
||||
expect(bulk.bulkActionOptions.value[0].value).toBe("select_all");
|
||||
|
||||
// With selection
|
||||
bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event);
|
||||
expect(bulk.bulkActionOptions.value.length).toBe(5);
|
||||
const values = bulk.bulkActionOptions.value.map((o) => o.value);
|
||||
expect(values).toContain("delete_selected");
|
||||
expect(values).toContain("enable_selected");
|
||||
expect(values).toContain("disable_selected");
|
||||
expect(values).toContain("export_selected");
|
||||
expect(values).toContain("clear_selection");
|
||||
});
|
||||
|
||||
it("select all action selects all visible", async () => {
|
||||
const { bulk } = setup([makeProfile(1, "Alice"), makeProfile(2, "Bob")]);
|
||||
bulk.bulkAction.value = "select_all" as ScholarBulkAction;
|
||||
await bulk.onApplyBulkAction();
|
||||
expect(bulk.selectedIds.value.size).toBe(2);
|
||||
});
|
||||
|
||||
it("clear selection action clears all", async () => {
|
||||
const { bulk } = setup([makeProfile(1, "Alice")]);
|
||||
bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event);
|
||||
bulk.bulkAction.value = "clear_selection" as ScholarBulkAction;
|
||||
await bulk.onApplyBulkAction();
|
||||
expect(bulk.selectedIds.value.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
import { computed, ref, watch, type Ref } from "vue";
|
||||
|
||||
import {
|
||||
bulkDeleteScholars,
|
||||
bulkToggleScholars,
|
||||
exportScholarData,
|
||||
type ScholarProfile,
|
||||
} from "@/features/scholars";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
|
||||
export type ScholarBulkAction =
|
||||
| "delete_selected"
|
||||
| "enable_selected"
|
||||
| "disable_selected"
|
||||
| "export_selected"
|
||||
| "clear_selection"
|
||||
| "select_all";
|
||||
|
||||
export interface ScholarBulkActionOption {
|
||||
value: ScholarBulkAction;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface BulkActionCallbacks {
|
||||
clearMessages: () => void;
|
||||
assignError: (error: unknown, fallback: string) => void;
|
||||
setSuccess: (msg: string) => void;
|
||||
reloadScholars: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useScholarBulkActions(
|
||||
visibleScholars: Ref<ScholarProfile[]>,
|
||||
callbacks: BulkActionCallbacks,
|
||||
) {
|
||||
const selectedIds = ref<Set<number>>(new Set());
|
||||
const bulkAction = ref<ScholarBulkAction>("select_all");
|
||||
const bulkBusy = ref(false);
|
||||
|
||||
const selectedCount = computed(() => selectedIds.value.size);
|
||||
const hasSelection = computed(() => selectedCount.value > 0);
|
||||
|
||||
const allVisibleSelected = computed(() => {
|
||||
if (visibleScholars.value.length === 0) return false;
|
||||
for (const item of visibleScholars.value) {
|
||||
if (!selectedIds.value.has(item.id)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const bulkActionOptions = computed<ScholarBulkActionOption[]>(() => {
|
||||
if (!hasSelection.value) return [{ value: "select_all", label: "Select all" }];
|
||||
const n = selectedCount.value;
|
||||
return [
|
||||
{ value: "delete_selected", label: `Delete selected (${n})` },
|
||||
{ value: "enable_selected", label: `Enable selected (${n})` },
|
||||
{ value: "disable_selected", label: `Disable selected (${n})` },
|
||||
{ value: "export_selected", label: `Export selected (${n})` },
|
||||
{ value: "clear_selection", label: "Clear selection" },
|
||||
];
|
||||
});
|
||||
|
||||
const bulkApplyLabel = computed(() => {
|
||||
if (bulkBusy.value) return "Applying...";
|
||||
if (bulkAction.value === "select_all") return "Select";
|
||||
if (bulkAction.value === "clear_selection") return "Clear";
|
||||
return "Apply";
|
||||
});
|
||||
|
||||
const bulkApplyDisabled = computed(() => {
|
||||
if (bulkBusy.value) return true;
|
||||
if (bulkAction.value === "select_all") return visibleScholars.value.length === 0;
|
||||
return selectedCount.value === 0;
|
||||
});
|
||||
|
||||
// Prune stale selections when visible list changes
|
||||
watch(visibleScholars, (items) => {
|
||||
const validIds = new Set(items.map((item) => item.id));
|
||||
const next = new Set<number>();
|
||||
for (const id of selectedIds.value) {
|
||||
if (validIds.has(id)) next.add(id);
|
||||
}
|
||||
if (next.size !== selectedIds.value.size) selectedIds.value = next;
|
||||
});
|
||||
|
||||
// Reset bulk action dropdown when selection state changes
|
||||
watch(hasSelection, (has) => {
|
||||
const validValues = new Set(bulkActionOptions.value.map((o) => o.value));
|
||||
if (validValues.has(bulkAction.value)) return;
|
||||
bulkAction.value = has ? "delete_selected" : "select_all";
|
||||
});
|
||||
|
||||
function onToggleAll(event: Event): void {
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
const next = new Set(selectedIds.value);
|
||||
for (const item of visibleScholars.value) {
|
||||
if (checked) { next.add(item.id); } else { next.delete(item.id); }
|
||||
}
|
||||
selectedIds.value = next;
|
||||
}
|
||||
|
||||
function onToggleRow(id: number, event: Event): void {
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
const next = new Set(selectedIds.value);
|
||||
if (checked) { next.add(id); } else { next.delete(id); }
|
||||
selectedIds.value = next;
|
||||
}
|
||||
|
||||
function downloadJsonFile(filename: string, payload: unknown): void {
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function onApplyBulkAction(): Promise<void> {
|
||||
if (bulkApplyDisabled.value) return;
|
||||
if (bulkAction.value === "select_all") {
|
||||
selectedIds.value = new Set(visibleScholars.value.map((item) => item.id));
|
||||
return;
|
||||
}
|
||||
if (bulkAction.value === "clear_selection") { selectedIds.value = new Set(); return; }
|
||||
if (bulkAction.value === "delete_selected") { await onBulkDelete(); return; }
|
||||
if (bulkAction.value === "enable_selected") { await onBulkToggle(true); return; }
|
||||
if (bulkAction.value === "disable_selected") { await onBulkToggle(false); return; }
|
||||
if (bulkAction.value === "export_selected") { await onBulkExport(); return; }
|
||||
}
|
||||
|
||||
async function onBulkDelete(): Promise<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;
|
||||
}
|
||||
}
|
||||
|
||||
async function onBulkToggle(isEnabled: boolean): Promise<void> {
|
||||
bulkBusy.value = true;
|
||||
callbacks.clearMessages();
|
||||
try {
|
||||
const ids = [...selectedIds.value];
|
||||
const result = await bulkToggleScholars(ids, isEnabled);
|
||||
const verb = isEnabled ? "enabled" : "disabled";
|
||||
callbacks.setSuccess(`${result.updated_count} scholar(s) ${verb}.`);
|
||||
selectedIds.value = new Set();
|
||||
await callbacks.reloadScholars();
|
||||
} catch (error) {
|
||||
callbacks.assignError(error, `Unable to bulk ${isEnabled ? "enable" : "disable"} scholars.`);
|
||||
} finally {
|
||||
bulkBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onBulkExport(): Promise<void> {
|
||||
bulkBusy.value = true;
|
||||
callbacks.clearMessages();
|
||||
try {
|
||||
const ids = [...selectedIds.value];
|
||||
const payload = await exportScholarData(ids);
|
||||
const dateSlug = payload.exported_at.slice(0, 10) || "unknown-date";
|
||||
downloadJsonFile(`scholarr-export-${dateSlug}.json`, payload);
|
||||
callbacks.setSuccess("Export complete.");
|
||||
} catch (error) {
|
||||
callbacks.assignError(error, "Unable to export selected scholars.");
|
||||
} finally {
|
||||
bulkBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
selectedCount,
|
||||
hasSelection,
|
||||
allVisibleSelected,
|
||||
bulkAction,
|
||||
bulkBusy,
|
||||
bulkActionOptions,
|
||||
bulkApplyLabel,
|
||||
bulkApplyDisabled,
|
||||
onToggleAll,
|
||||
onToggleRow,
|
||||
onApplyBulkAction,
|
||||
};
|
||||
}
|
||||
|
|
@ -160,8 +160,30 @@ export async function clearScholarImage(
|
|||
return response.data;
|
||||
}
|
||||
|
||||
export async function exportScholarData(): Promise<DataExportPayload> {
|
||||
const response = await apiRequest<DataExportPayload>("/scholars/export", {
|
||||
export interface BulkCountResult {
|
||||
deleted_count: number;
|
||||
updated_count: number;
|
||||
}
|
||||
|
||||
export async function bulkDeleteScholars(scholarProfileIds: number[]): Promise<BulkCountResult> {
|
||||
const response = await apiRequest<BulkCountResult>("/scholars/bulk-delete", {
|
||||
method: "POST",
|
||||
body: { scholar_profile_ids: scholarProfileIds },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function bulkToggleScholars(scholarProfileIds: number[], isEnabled: boolean): Promise<BulkCountResult> {
|
||||
const response = await apiRequest<BulkCountResult>("/scholars/bulk-toggle", {
|
||||
method: "POST",
|
||||
body: { scholar_profile_ids: scholarProfileIds, is_enabled: isEnabled },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function exportScholarData(ids?: number[]): Promise<DataExportPayload> {
|
||||
const params = ids && ids.length > 0 ? `?ids=${ids.join(",")}` : "";
|
||||
const response = await apiRequest<DataExportPayload>(`/scholars/export${params}`, {
|
||||
method: "GET",
|
||||
});
|
||||
return response.data;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue