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:
Justin Visser 2026-03-07 14:03:06 +01:00
parent 6fa54db08f
commit 7f78dd6353
9 changed files with 730 additions and 79 deletions

View file

@ -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);
});
});

View file

@ -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,
};
}

View file

@ -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;

View file

@ -27,6 +27,7 @@ import {
type ScholarProfile,
type ScholarSearchCandidate,
} from "@/features/scholars";
import { useScholarBulkActions } from "@/features/scholars/composables/useScholarBulkActions";
import ScholarAvatar from "@/features/scholars/components/ScholarAvatar.vue";
import ScholarBatchAdd from "@/features/scholars/components/ScholarBatchAdd.vue";
import ScholarNameSearch from "@/features/scholars/components/ScholarNameSearch.vue";
@ -290,82 +291,44 @@ async function onDeleteScholar(): Promise<void> {
}
async function onSaveImageUrl(): Promise<void> {
const profile = activeScholarSettings.value;
if (!profile) return;
const candidate = (imageUrlDraftByScholarId.value[profile.id] || "").trim();
if (!candidate) { errorMessage.value = "Enter an image URL before saving, or use Reset image."; return; }
imageSavingScholarId.value = profile.id;
const p = activeScholarSettings.value;
if (!p) return;
const url = (imageUrlDraftByScholarId.value[p.id] || "").trim();
if (!url) { errorMessage.value = "Enter an image URL before saving, or use Reset image."; return; }
imageSavingScholarId.value = p.id;
clearMessages();
try {
await setScholarImageUrl(profile.id, candidate);
successMessage.value = `Image URL updated for ${scholarLabel(profile)}.`;
await loadScholars();
} catch (error) {
assignError(error, "Unable to update scholar image URL.");
} finally {
imageSavingScholarId.value = null;
}
try { await setScholarImageUrl(p.id, url); successMessage.value = `Image URL updated for ${scholarLabel(p)}.`; await loadScholars(); }
catch (e) { assignError(e, "Unable to update scholar image URL."); }
finally { imageSavingScholarId.value = null; }
}
async function onUploadImage(event: Event): Promise<void> {
const profile = activeScholarSettings.value;
if (!profile) return;
const p = activeScholarSettings.value;
if (!p) return;
const input = event.target as HTMLInputElement | null;
const file = input?.files?.[0] ?? null;
if (!file) return;
imageUploadingScholarId.value = profile.id;
imageUploadingScholarId.value = p.id;
clearMessages();
try {
await uploadScholarImage(profile.id, file);
successMessage.value = `Uploaded image for ${scholarLabel(profile)}.`;
await loadScholars();
} catch (error) {
assignError(error, "Unable to upload scholar image.");
} finally {
imageUploadingScholarId.value = null;
if (input) input.value = "";
}
try { await uploadScholarImage(p.id, file); successMessage.value = `Uploaded image for ${scholarLabel(p)}.`; await loadScholars(); }
catch (e) { assignError(e, "Unable to upload scholar image."); }
finally { imageUploadingScholarId.value = null; if (input) input.value = ""; }
}
async function onResetImage(): Promise<void> {
const profile = activeScholarSettings.value;
if (!profile) return;
imageSavingScholarId.value = profile.id;
const p = activeScholarSettings.value;
if (!p) return;
imageSavingScholarId.value = p.id;
clearMessages();
try {
await clearScholarImage(profile.id);
successMessage.value = `Image reset for ${scholarLabel(profile)}.`;
await loadScholars();
} catch (error) {
assignError(error, "Unable to reset scholar image.");
} finally {
imageSavingScholarId.value = null;
}
try { await clearScholarImage(p.id); successMessage.value = `Image reset for ${scholarLabel(p)}.`; await loadScholars(); }
catch (e) { assignError(e, "Unable to reset scholar image."); }
finally { imageSavingScholarId.value = null; }
}
// --- Import/export ---
function suggestExportFilename(exportedAt: string): string {
return `scholarr-export-${exportedAt.slice(0, 10) || "unknown-date"}.json`;
}
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);
}
function importSummary(result: DataImportResult): string {
return (
`Import complete. Scholars +${result.scholars_created}` +
` / updated ${result.scholars_updated}; publications +${result.publications_created}` +
` / updated ${result.publications_updated}; links +${result.links_created}` +
` / updated ${result.links_updated}; skipped ${result.skipped_records}.`
);
function importSummary(r: DataImportResult): string {
return `Import complete. Scholars +${r.scholars_created} / updated ${r.scholars_updated}; publications +${r.publications_created} / updated ${r.publications_updated}; links +${r.links_created} / updated ${r.links_updated}; skipped ${r.skipped_records}.`;
}
async function onExportData(): Promise<void> {
@ -373,7 +336,13 @@ async function onExportData(): Promise<void> {
clearMessages();
try {
const payload = await exportScholarData();
downloadJsonFile(suggestExportFilename(payload.exported_at), payload);
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `scholarr-export-${payload.exported_at.slice(0, 10) || "unknown-date"}.json`;
a.click();
URL.revokeObjectURL(url);
successMessage.value = "Export complete.";
} catch (error) {
assignError(error, "Unable to export scholars and publications.");
@ -382,10 +351,6 @@ async function onExportData(): Promise<void> {
}
}
function onOpenImportPicker(): void {
importFileInput.value?.click();
}
async function onImportFileSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement | null;
const file = input?.files?.[0] ?? null;
@ -395,16 +360,12 @@ async function onImportFileSelected(event: Event): Promise<void> {
try {
const raw = await file.text();
let parsed = JSON.parse(raw);
// Accept the full export envelope (with data/meta wrapper)
if (parsed?.data && Array.isArray(parsed.data.scholars)) {
parsed = parsed.data;
}
if (parsed?.data && Array.isArray(parsed.data.scholars)) parsed = parsed.data;
const payload = parsed as DataImportPayload;
if (!payload || !Array.isArray(payload.scholars) || !Array.isArray(payload.publications)) {
throw new Error("Invalid import file: expected scholars[] and publications[] arrays.");
}
const result = await importScholarData(payload);
successMessage.value = importSummary(result);
successMessage.value = importSummary(await importScholarData(payload));
await loadScholars();
} catch (error) {
assignError(error, "Unable to import scholars and publications.");
@ -414,6 +375,15 @@ async function onImportFileSelected(event: Event): Promise<void> {
}
}
// --- Bulk actions ---
const bulk = useScholarBulkActions(visibleScholars, {
clearMessages,
assignError,
setSuccess: (msg: string) => { successMessage.value = msg; },
reloadScholars: loadScholars,
});
// --- Lifecycle ---
onMounted(() => { void loadScholars(); });
@ -471,7 +441,7 @@ watch(
<AppButton variant="secondary" :disabled="loading || exportingData" @click="onExportData">
{{ exportingData ? "Exporting..." : "Export" }}
</AppButton>
<AppButton variant="secondary" :disabled="loading || importingData" @click="onOpenImportPicker">
<AppButton variant="secondary" :disabled="loading || importingData" @click="importFileInput?.click()">
{{ importingData ? "Importing..." : "Import" }}
</AppButton>
<AppRefreshButton variant="secondary" :disabled="saving" :loading="loading" title="Refresh scholars" loading-title="Refreshing scholars" @click="loadScholars" />
@ -496,6 +466,34 @@ watch(
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-stroke-default pt-2">
<span class="text-xs text-secondary">
{{ trackedCountLabel }}
<template v-if="bulk.hasSelection.value"> · {{ bulk.selectedCount.value }} selected</template>
</span>
<div class="flex items-center gap-1">
<label for="scholars-bulk-action" class="sr-only">Bulk action</label>
<AppSelect
id="scholars-bulk-action"
v-model="bulk.bulkAction.value"
:disabled="bulk.bulkBusy.value || loading"
class="max-w-[14rem] !py-1.5 !text-xs"
>
<option v-for="option in bulk.bulkActionOptions.value" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</AppSelect>
<AppButton
variant="secondary"
class="h-8 min-h-8 shrink-0 px-2 text-xs"
:disabled="bulk.bulkApplyDisabled.value"
@click="bulk.onApplyBulkAction"
>
{{ bulk.bulkApplyLabel.value }}
</AppButton>
</div>
</div>
<div class="min-h-0 flex-1 xl:overflow-hidden">
<AsyncStateGate :loading="loading" :loading-lines="6" :empty="!hasTrackedScholars" :show-empty="!errorMessage" empty-title="No scholars tracked" empty-body="Add a Scholar ID or URL to start ingestion tracking.">
<AppEmptyState v-if="!hasVisibleScholars" title="No scholars match this filter" body="Clear or adjust the filter to see tracked scholars." />
@ -503,6 +501,13 @@ watch(
<ul class="flex gap-3 overflow-x-auto p-1 lg:hidden">
<li v-for="item in visibleScholars" :key="item.id" class="rounded-xl border border-stroke-default bg-surface-card-muted/70 p-3">
<div class="flex items-start gap-3">
<input
type="checkbox"
class="bulk-check mt-1 shrink-0"
:checked="bulk.selectedIds.value.has(item.id)"
:aria-label="`Select ${scholarLabel(item)}`"
@change="bulk.onToggleRow(item.id, $event)"
/>
<ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" />
<div class="min-w-0 flex-1 space-y-1">
<p class="truncate text-sm font-semibold text-ink-primary">{{ scholarLabel(item) }}</p>
@ -519,12 +524,31 @@ watch(
<AppTable class="h-full overflow-y-scroll overscroll-contain" label="Tracked scholars table">
<thead>
<tr>
<th scope="col" class="w-10">
<input
type="checkbox"
class="bulk-check"
:checked="bulk.allVisibleSelected.value"
:disabled="visibleScholars.length === 0"
aria-label="Select all visible scholars"
@change="bulk.onToggleAll"
/>
</th>
<th scope="col">Scholar</th>
<th scope="col" class="w-[11rem]">Manage</th>
</tr>
</thead>
<tbody>
<tr v-for="item in visibleScholars" :key="item.id">
<td>
<input
type="checkbox"
class="bulk-check"
:checked="bulk.selectedIds.value.has(item.id)"
:aria-label="`Select ${scholarLabel(item)}`"
@change="bulk.onToggleRow(item.id, $event)"
/>
</td>
<td>
<div class="flex items-start gap-3">
<ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" />
@ -568,3 +592,9 @@ watch(
/>
</AppPage>
</template>
<style scoped>
.bulk-check {
@apply h-4 w-4 rounded border-stroke-interactive bg-surface-input text-brand-600 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset;
}
</style>