after audit
This commit is contained in:
parent
e8e20637e6
commit
a5165dc3ba
80 changed files with 8489 additions and 7302 deletions
51
frontend/src/composables/useRequestState.test.ts
Normal file
51
frontend/src/composables/useRequestState.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
import { useRequestState } from "@/composables/useRequestState";
|
||||
|
||||
describe("useRequestState", () => {
|
||||
it("initializes with all values null", () => {
|
||||
const { errorMessage, errorRequestId, successMessage } = useRequestState();
|
||||
expect(errorMessage.value).toBeNull();
|
||||
expect(errorRequestId.value).toBeNull();
|
||||
expect(successMessage.value).toBeNull();
|
||||
});
|
||||
|
||||
it("assignError extracts message and requestId from ApiRequestError", () => {
|
||||
const { errorMessage, errorRequestId, assignError } = useRequestState();
|
||||
assignError(
|
||||
new ApiRequestError({ status: 409, code: "conflict", message: "Conflict occurred", requestId: "req-42" }),
|
||||
"fallback",
|
||||
);
|
||||
expect(errorMessage.value).toBe("Conflict occurred");
|
||||
expect(errorRequestId.value).toBe("req-42");
|
||||
});
|
||||
|
||||
it("assignError uses Error.message for generic errors", () => {
|
||||
const { errorMessage, errorRequestId, assignError } = useRequestState();
|
||||
assignError(new Error("something broke"), "fallback");
|
||||
expect(errorMessage.value).toBe("something broke");
|
||||
expect(errorRequestId.value).toBeNull();
|
||||
});
|
||||
|
||||
it("assignError uses fallback for non-Error values", () => {
|
||||
const { errorMessage, assignError } = useRequestState();
|
||||
assignError("not an error object", "fallback text");
|
||||
expect(errorMessage.value).toBe("fallback text");
|
||||
});
|
||||
|
||||
it("setSuccess sets the success message", () => {
|
||||
const { successMessage, setSuccess } = useRequestState();
|
||||
setSuccess("Operation completed");
|
||||
expect(successMessage.value).toBe("Operation completed");
|
||||
});
|
||||
|
||||
it("clearAlerts resets all messages", () => {
|
||||
const { errorMessage, errorRequestId, successMessage, assignError, setSuccess, clearAlerts } = useRequestState();
|
||||
assignError(new ApiRequestError({ status: 500, code: "err", message: "fail", requestId: "r1" }), "fb");
|
||||
setSuccess("ok");
|
||||
clearAlerts();
|
||||
expect(errorMessage.value).toBeNull();
|
||||
expect(errorRequestId.value).toBeNull();
|
||||
expect(successMessage.value).toBeNull();
|
||||
});
|
||||
});
|
||||
40
frontend/src/composables/useRequestState.ts
Normal file
40
frontend/src/composables/useRequestState.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { ref } from "vue";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
|
||||
export function useRequestState() {
|
||||
const errorMessage = ref<string | null>(null);
|
||||
const errorRequestId = ref<string | null>(null);
|
||||
const successMessage = ref<string | null>(null);
|
||||
|
||||
function clearAlerts(): void {
|
||||
errorMessage.value = null;
|
||||
errorRequestId.value = null;
|
||||
successMessage.value = null;
|
||||
}
|
||||
|
||||
function assignError(error: unknown, fallback: string): void {
|
||||
if (error instanceof ApiRequestError) {
|
||||
errorMessage.value = error.message;
|
||||
errorRequestId.value = error.requestId;
|
||||
return;
|
||||
}
|
||||
if (error instanceof Error && error.message) {
|
||||
errorMessage.value = error.message;
|
||||
return;
|
||||
}
|
||||
errorMessage.value = fallback;
|
||||
}
|
||||
|
||||
function setSuccess(message: string): void {
|
||||
successMessage.value = message;
|
||||
}
|
||||
|
||||
return {
|
||||
errorMessage,
|
||||
errorRequestId,
|
||||
successMessage,
|
||||
clearAlerts,
|
||||
assignError,
|
||||
setSuccess,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import PublicationTableRow from "./PublicationTableRow.vue";
|
||||
import type { PublicationItem } from "@/features/publications";
|
||||
|
||||
function buildItem(overrides: Partial<PublicationItem> = {}): PublicationItem {
|
||||
return {
|
||||
publication_id: 1,
|
||||
scholar_profile_id: 10,
|
||||
title: "Test Publication",
|
||||
year: 2025,
|
||||
citation_count: 42,
|
||||
pub_url: "https://example.com/pub",
|
||||
pdf_url: null,
|
||||
pdf_status: null,
|
||||
is_read: false,
|
||||
is_favorite: false,
|
||||
is_new_in_latest_run: true,
|
||||
scholar_label: "Dr. Test",
|
||||
first_seen_at: "2025-01-15T00:00:00Z",
|
||||
display_identifier: null,
|
||||
...overrides,
|
||||
} as PublicationItem;
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
item: buildItem(),
|
||||
itemKey: "pub-1",
|
||||
selected: false,
|
||||
favoriteUpdating: false,
|
||||
retrying: false,
|
||||
canRetry: false,
|
||||
};
|
||||
|
||||
describe("PublicationTableRow", () => {
|
||||
it("renders the publication title", () => {
|
||||
const wrapper = mount(PublicationTableRow, { props: defaultProps });
|
||||
expect(wrapper.text()).toContain("Test Publication");
|
||||
});
|
||||
|
||||
it("renders scholar label", () => {
|
||||
const wrapper = mount(PublicationTableRow, { props: defaultProps });
|
||||
expect(wrapper.text()).toContain("Dr. Test");
|
||||
});
|
||||
|
||||
it("renders year and citation count", () => {
|
||||
const wrapper = mount(PublicationTableRow, { props: defaultProps });
|
||||
expect(wrapper.text()).toContain("2025");
|
||||
expect(wrapper.text()).toContain("42");
|
||||
});
|
||||
|
||||
it("shows New badge when is_new_in_latest_run is true", () => {
|
||||
const wrapper = mount(PublicationTableRow, { props: defaultProps });
|
||||
expect(wrapper.text()).toContain("New");
|
||||
});
|
||||
|
||||
it("shows Seen badge when is_new_in_latest_run is false", () => {
|
||||
const wrapper = mount(PublicationTableRow, {
|
||||
props: { ...defaultProps, item: buildItem({ is_new_in_latest_run: false }) },
|
||||
});
|
||||
expect(wrapper.text()).toContain("Seen");
|
||||
});
|
||||
|
||||
it("shows Unread badge for unread items", () => {
|
||||
const wrapper = mount(PublicationTableRow, { props: defaultProps });
|
||||
expect(wrapper.text()).toContain("Unread");
|
||||
});
|
||||
|
||||
it("shows Read badge for read items", () => {
|
||||
const wrapper = mount(PublicationTableRow, {
|
||||
props: { ...defaultProps, item: buildItem({ is_read: true }) },
|
||||
});
|
||||
expect(wrapper.text()).toContain("Read");
|
||||
});
|
||||
|
||||
it("disables checkbox when item is read", () => {
|
||||
const wrapper = mount(PublicationTableRow, {
|
||||
props: { ...defaultProps, item: buildItem({ is_read: true }) },
|
||||
});
|
||||
const checkbox = wrapper.find('input[type="checkbox"]');
|
||||
expect((checkbox.element as HTMLInputElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("emits toggle-selection when checkbox is changed", async () => {
|
||||
const wrapper = mount(PublicationTableRow, { props: defaultProps });
|
||||
await wrapper.find('input[type="checkbox"]').trigger("change");
|
||||
expect(wrapper.emitted("toggle-selection")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("emits toggle-favorite when star button is clicked", async () => {
|
||||
const wrapper = mount(PublicationTableRow, { props: defaultProps });
|
||||
await wrapper.find(".favorite-star-button").trigger("click");
|
||||
expect(wrapper.emitted("toggle-favorite")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows filled star when item is favorite", () => {
|
||||
const wrapper = mount(PublicationTableRow, {
|
||||
props: { ...defaultProps, item: buildItem({ is_favorite: true }) },
|
||||
});
|
||||
expect(wrapper.find(".favorite-star-on").exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain("★");
|
||||
});
|
||||
|
||||
it("shows empty star when item is not favorite", () => {
|
||||
const wrapper = mount(PublicationTableRow, { props: defaultProps });
|
||||
expect(wrapper.find(".favorite-star-off").exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain("☆");
|
||||
});
|
||||
|
||||
it("shows Available link when pdf_url is set", () => {
|
||||
const wrapper = mount(PublicationTableRow, {
|
||||
props: { ...defaultProps, item: buildItem({ pdf_url: "https://example.com/paper.pdf" }) },
|
||||
});
|
||||
expect(wrapper.text()).toContain("Available");
|
||||
});
|
||||
|
||||
it("shows retry button when canRetry is true and no pdf_url", () => {
|
||||
const wrapper = mount(PublicationTableRow, {
|
||||
props: { ...defaultProps, canRetry: true },
|
||||
});
|
||||
expect(wrapper.text()).toContain("Missing (Retry)");
|
||||
});
|
||||
|
||||
it("emits retry-pdf when retry button is clicked", async () => {
|
||||
const wrapper = mount(PublicationTableRow, {
|
||||
props: { ...defaultProps, canRetry: true },
|
||||
});
|
||||
await wrapper.find(".pdf-retry-button").trigger("click");
|
||||
expect(wrapper.emitted("retry-pdf")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows Retrying... label when retrying", () => {
|
||||
const wrapper = mount(PublicationTableRow, {
|
||||
props: { ...defaultProps, canRetry: true, retrying: true },
|
||||
});
|
||||
expect(wrapper.text()).toContain("Retrying...");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
<script setup lang="ts">
|
||||
import AppBadge from "@/components/ui/AppBadge.vue";
|
||||
import type { PublicationItem } from "@/features/publications";
|
||||
|
||||
const props = defineProps<{
|
||||
item: PublicationItem;
|
||||
itemKey: string;
|
||||
selected: boolean;
|
||||
favoriteUpdating: boolean;
|
||||
retrying: boolean;
|
||||
canRetry: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "toggle-selection", event: Event): void;
|
||||
(e: "toggle-favorite"): void;
|
||||
(e: "retry-pdf"): void;
|
||||
}>();
|
||||
|
||||
function primaryUrl(): string | null {
|
||||
return props.item.pub_url || props.item.pdf_url;
|
||||
}
|
||||
|
||||
function identifierUrl(): string | null {
|
||||
return props.item.display_identifier?.url ?? null;
|
||||
}
|
||||
|
||||
function identifierLabel(): string | null {
|
||||
return props.item.display_identifier?.label ?? null;
|
||||
}
|
||||
|
||||
function pdfPendingLabel(): string {
|
||||
if (props.item.pdf_status === "queued") return "Queued";
|
||||
if (props.item.pdf_status === "running") return "Resolving...";
|
||||
if (props.item.pdf_status === "failed") return "Missing";
|
||||
return "Untracked";
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const asDate = new Date(value);
|
||||
if (Number.isNaN(asDate.getTime())) return value;
|
||||
return asDate.toLocaleDateString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="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"
|
||||
:checked="selected"
|
||||
:disabled="item.is_read"
|
||||
:aria-label="`Select publication ${item.title}`"
|
||||
@change="emit('toggle-selection', $event)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="favorite-star-button"
|
||||
:class="item.is_favorite ? 'favorite-star-on' : 'favorite-star-off'"
|
||||
:aria-label="item.is_favorite ? `Remove ${item.title} from favorites` : `Add ${item.title} to favorites`"
|
||||
:aria-pressed="item.is_favorite"
|
||||
:disabled="favoriteUpdating"
|
||||
@click="emit('toggle-favorite')"
|
||||
>
|
||||
{{ item.is_favorite ? "★" : "☆" }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="max-w-0">
|
||||
<div class="grid min-w-0 gap-1">
|
||||
<a
|
||||
v-if="primaryUrl()"
|
||||
:href="primaryUrl() || ''"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="link-inline block truncate font-medium"
|
||||
:title="item.title"
|
||||
>
|
||||
{{ item.title }}
|
||||
</a>
|
||||
<span v-else class="block truncate font-medium" :title="item.title">{{ item.title }}</span>
|
||||
<a
|
||||
v-if="identifierUrl()"
|
||||
:href="identifierUrl() || ''"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="link-inline block truncate text-xs"
|
||||
:title="identifierLabel() || ''"
|
||||
>
|
||||
{{ identifierLabel() }}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="block truncate" :title="item.scholar_label">{{ item.scholar_label }}</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap">
|
||||
<a
|
||||
v-if="item.pdf_url"
|
||||
:href="item.pdf_url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="pdf-link-button"
|
||||
title="Open PDF"
|
||||
>
|
||||
<svg class="mr-1 h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Available
|
||||
</a>
|
||||
<button
|
||||
v-else-if="canRetry"
|
||||
type="button"
|
||||
class="pdf-retry-button"
|
||||
:disabled="retrying"
|
||||
@click="emit('retry-pdf')"
|
||||
>
|
||||
<svg v-if="retrying" class="mr-1 h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ retrying ? "Retrying..." : "Missing (Retry)" }}
|
||||
</button>
|
||||
<span v-else class="pdf-state-label" :class="{ 'bg-surface-accent-muted border-accent-300 text-accent-700': item.pdf_status === 'running' || item.pdf_status === 'queued' }">
|
||||
<svg v-if="item.pdf_status === 'running'" class="mr-1 h-3 w-3 animate-spin text-accent-600" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ pdfPendingLabel() }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap">{{ item.year ?? "n/a" }}</td>
|
||||
<td class="whitespace-nowrap">{{ item.citation_count }}</td>
|
||||
<td>
|
||||
<div class="status-badges-row">
|
||||
<AppBadge :tone="item.is_new_in_latest_run ? 'info' : 'neutral'">
|
||||
{{ item.is_new_in_latest_run ? "New" : "Seen" }}
|
||||
</AppBadge>
|
||||
<AppBadge :tone="item.is_read ? 'success' : 'warning'">
|
||||
{{ item.is_read ? "Read" : "Unread" }}
|
||||
</AppBadge>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap">{{ formatDate(item.first_seen_at) }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.favorite-star-button {
|
||||
@apply inline-flex h-7 w-7 items-center justify-center rounded-full border text-sm leading-none transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
.favorite-star-on {
|
||||
@apply border-warning-300 bg-warning-100 text-warning-700 hover:bg-warning-200;
|
||||
}
|
||||
|
||||
.favorite-star-off {
|
||||
@apply border-stroke-default bg-surface-card-muted text-ink-muted hover:border-stroke-interactive hover:text-ink-secondary;
|
||||
}
|
||||
|
||||
.pdf-link-button {
|
||||
@apply inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium border-state-success-border bg-state-success-bg text-state-success-text shadow-sm transition hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset;
|
||||
}
|
||||
|
||||
.pdf-retry-button {
|
||||
@apply inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium border-state-warning-border bg-state-warning-bg text-state-warning-text transition hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
.pdf-state-label {
|
||||
@apply inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium border-stroke-default bg-surface-card-muted text-secondary;
|
||||
}
|
||||
|
||||
.status-badges-row {
|
||||
@apply inline-flex items-center gap-1 whitespace-nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import PublicationToolbar from "./PublicationToolbar.vue";
|
||||
|
||||
const defaultProps = {
|
||||
mode: "all" as const,
|
||||
selectedScholarFilter: "",
|
||||
searchQuery: "",
|
||||
favoriteOnly: false,
|
||||
actionBusy: false,
|
||||
loading: false,
|
||||
scholars: [],
|
||||
startRunDisabled: false,
|
||||
startRunDisabledReason: null,
|
||||
startRunButtonLabel: "Check now",
|
||||
};
|
||||
|
||||
describe("PublicationToolbar", () => {
|
||||
it("renders mode, scholar filter, and search inputs", () => {
|
||||
const wrapper = mount(PublicationToolbar, { props: defaultProps });
|
||||
expect(wrapper.text()).toContain("Status");
|
||||
expect(wrapper.text()).toContain("Scholar");
|
||||
expect(wrapper.text()).toContain("Search");
|
||||
});
|
||||
|
||||
it("renders the start run button with provided label", () => {
|
||||
const wrapper = mount(PublicationToolbar, { props: defaultProps });
|
||||
expect(wrapper.text()).toContain("Check now");
|
||||
});
|
||||
|
||||
it("disables start run button when startRunDisabled is true", () => {
|
||||
const wrapper = mount(PublicationToolbar, {
|
||||
props: { ...defaultProps, startRunDisabled: true, startRunDisabledReason: "Cooldown active" },
|
||||
});
|
||||
const buttons = wrapper.findAll("button");
|
||||
const runButton = buttons.find((b) => b.text().includes("Check now"));
|
||||
expect(runButton?.attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("emits start-run when start button is clicked", async () => {
|
||||
const wrapper = mount(PublicationToolbar, { props: defaultProps });
|
||||
const buttons = wrapper.findAll("button");
|
||||
const runButton = buttons.find((b) => b.text().includes("Check now"));
|
||||
await runButton?.trigger("click");
|
||||
expect(wrapper.emitted("start-run")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("emits favorite-only-changed when star filter is clicked", async () => {
|
||||
const wrapper = mount(PublicationToolbar, { props: defaultProps });
|
||||
await wrapper.find(".favorite-filter-button").trigger("click");
|
||||
expect(wrapper.emitted("favorite-only-changed")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows active star filter style when favoriteOnly is true", () => {
|
||||
const wrapper = mount(PublicationToolbar, { props: { ...defaultProps, favoriteOnly: true } });
|
||||
expect(wrapper.find(".favorite-filter-on").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows inactive star filter style when favoriteOnly is false", () => {
|
||||
const wrapper = mount(PublicationToolbar, { props: defaultProps });
|
||||
expect(wrapper.find(".favorite-filter-off").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("renders scholar options from props", () => {
|
||||
const scholars = [
|
||||
{ id: 1, scholar_id: "abc123", display_name: "Alice", profile_image_url: null, profile_image_source: "none" as const, is_enabled: true, baseline_completed: true, last_run_dt: null, last_run_status: null },
|
||||
{ id: 2, scholar_id: "def456", display_name: "", profile_image_url: null, profile_image_source: "none" as const, is_enabled: true, baseline_completed: true, last_run_dt: null, last_run_status: null },
|
||||
];
|
||||
const wrapper = mount(PublicationToolbar, { props: { ...defaultProps, scholars } });
|
||||
expect(wrapper.text()).toContain("Alice");
|
||||
expect(wrapper.text()).toContain("def456");
|
||||
});
|
||||
|
||||
it("shows clear button only when search query is non-empty", () => {
|
||||
const empty = mount(PublicationToolbar, { props: defaultProps });
|
||||
expect(empty.text()).not.toContain("Clear");
|
||||
|
||||
const withQuery = mount(PublicationToolbar, { props: { ...defaultProps, searchQuery: "test" } });
|
||||
expect(withQuery.text()).toContain("Clear");
|
||||
});
|
||||
|
||||
it("emits reset-search when clear button is clicked", async () => {
|
||||
const wrapper = mount(PublicationToolbar, { props: { ...defaultProps, searchQuery: "test" } });
|
||||
const clearButton = wrapper.findAll("button").find((b) => b.text().includes("Clear"));
|
||||
await clearButton?.trigger("click");
|
||||
expect(wrapper.emitted("reset-search")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
<script setup lang="ts">
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
|
||||
import AppSelect from "@/components/ui/AppSelect.vue";
|
||||
import type { PublicationMode } from "@/features/publications";
|
||||
import type { ScholarProfile } from "@/features/scholars";
|
||||
|
||||
defineProps<{
|
||||
mode: PublicationMode;
|
||||
selectedScholarFilter: string;
|
||||
searchQuery: string;
|
||||
favoriteOnly: boolean;
|
||||
actionBusy: boolean;
|
||||
loading: boolean;
|
||||
scholars: ScholarProfile[];
|
||||
startRunDisabled: boolean;
|
||||
startRunDisabledReason: string | null;
|
||||
startRunButtonLabel: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:mode", value: PublicationMode): void;
|
||||
(e: "update:selectedScholarFilter", value: string): void;
|
||||
(e: "update:searchQuery", value: string): void;
|
||||
(e: "mode-changed"): void;
|
||||
(e: "scholar-filter-changed"): void;
|
||||
(e: "favorite-only-changed"): void;
|
||||
(e: "reset-search"): void;
|
||||
(e: "start-run"): void;
|
||||
(e: "refresh"): void;
|
||||
}>();
|
||||
|
||||
function scholarLabel(item: ScholarProfile): string {
|
||||
return item.display_name || item.scholar_id;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-3 xl:grid-cols-[minmax(0,13rem)_minmax(0,18rem)_minmax(0,1fr)_auto] xl:items-end">
|
||||
<div class="grid gap-1 text-xs text-secondary">
|
||||
<div class="flex items-center gap-1">
|
||||
<span>Status</span>
|
||||
<AppHelpHint text="All shows full history. Unread and New narrow the dataset before search." />
|
||||
</div>
|
||||
<AppSelect
|
||||
:model-value="mode"
|
||||
:disabled="actionBusy"
|
||||
@update:model-value="emit('update:mode', $event as PublicationMode)"
|
||||
@change="emit('mode-changed')"
|
||||
>
|
||||
<option value="all">All records</option>
|
||||
<option value="unread">Unread</option>
|
||||
<option value="latest">New (latest run)</option>
|
||||
</AppSelect>
|
||||
</div>
|
||||
|
||||
<label class="grid gap-1 text-xs text-secondary" for="publications-scholar-filter">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Scholar
|
||||
<AppHelpHint text="Filter to one tracked scholar profile. Filter is synced to URL query." />
|
||||
</span>
|
||||
<AppSelect
|
||||
id="publications-scholar-filter"
|
||||
:model-value="selectedScholarFilter"
|
||||
:disabled="actionBusy"
|
||||
@update:model-value="emit('update:selectedScholarFilter', $event as string)"
|
||||
@change="emit('scholar-filter-changed')"
|
||||
>
|
||||
<option value="">All scholars</option>
|
||||
<option v-for="scholar in scholars" :key="scholar.id" :value="String(scholar.id)">
|
||||
{{ scholarLabel(scholar) }}
|
||||
</option>
|
||||
</AppSelect>
|
||||
</label>
|
||||
|
||||
<label class="grid gap-1 text-xs text-secondary" for="publications-search-input">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Search
|
||||
<AppHelpHint text="Searches title, scholar name, and venue." />
|
||||
</span>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<AppInput
|
||||
id="publications-search-input"
|
||||
:model-value="searchQuery"
|
||||
placeholder="Search title, scholar, venue, year"
|
||||
:disabled="loading"
|
||||
@update:model-value="emit('update:searchQuery', $event as string)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="searchQuery.trim().length > 0"
|
||||
variant="secondary"
|
||||
class="shrink-0"
|
||||
:disabled="loading"
|
||||
@click="emit('reset-search')"
|
||||
>
|
||||
Clear
|
||||
</AppButton>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-wrap items-end justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="favorite-filter-button"
|
||||
:class="favoriteOnly ? 'favorite-filter-on' : 'favorite-filter-off'"
|
||||
:disabled="actionBusy"
|
||||
:title="favoriteOnly ? 'Favorites-only filter is active' : 'Show only favorites'"
|
||||
:aria-pressed="favoriteOnly"
|
||||
:aria-label="favoriteOnly ? 'Disable favorites-only filter' : 'Enable favorites-only filter'"
|
||||
@click="emit('favorite-only-changed')"
|
||||
>
|
||||
<span aria-hidden="true">{{ favoriteOnly ? "★" : "☆" }}</span>
|
||||
</button>
|
||||
<AppButton
|
||||
variant="secondary"
|
||||
:disabled="startRunDisabled"
|
||||
:title="startRunDisabledReason || undefined"
|
||||
@click="emit('start-run')"
|
||||
>
|
||||
{{ startRunButtonLabel }}
|
||||
</AppButton>
|
||||
<AppRefreshButton
|
||||
variant="ghost"
|
||||
:disabled="loading"
|
||||
:loading="loading"
|
||||
title="Refresh publications"
|
||||
loading-title="Refreshing publications"
|
||||
@click="emit('refresh')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.favorite-filter-button {
|
||||
@apply inline-flex min-h-10 h-10 w-10 items-center justify-center rounded-full border text-base leading-none transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
.favorite-filter-on {
|
||||
@apply border-warning-300 bg-warning-100 text-warning-700 hover:bg-warning-200;
|
||||
}
|
||||
|
||||
.favorite-filter-off {
|
||||
@apply border-stroke-default bg-surface-card-muted text-ink-muted hover:border-stroke-interactive hover:text-ink-secondary;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
useRoute: vi.fn(() => ({ query: {} })),
|
||||
useRouter: vi.fn(() => ({ replace: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/publications", async (importOriginal) => {
|
||||
const original = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...original,
|
||||
listPublications: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/features/scholars", () => ({
|
||||
listScholars: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listPublications } from "@/features/publications";
|
||||
import { listScholars } from "@/features/scholars";
|
||||
import { usePublicationData } from "./usePublicationData";
|
||||
|
||||
const mockedListPublications = vi.mocked(listPublications);
|
||||
const mockedListScholars = vi.mocked(listScholars);
|
||||
|
||||
describe("usePublicationData", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
mockedListPublications.mockReset();
|
||||
mockedListScholars.mockReset();
|
||||
});
|
||||
|
||||
it("initializes with default filter values", () => {
|
||||
const data = usePublicationData();
|
||||
expect(data.mode.value).toBe("all");
|
||||
expect(data.favoriteOnly.value).toBe(false);
|
||||
expect(data.searchQuery.value).toBe("");
|
||||
expect(data.selectedScholarFilter.value).toBe("");
|
||||
expect(data.loading.value).toBe(true);
|
||||
});
|
||||
|
||||
it("loadScholarFilters calls listScholars", async () => {
|
||||
mockedListScholars.mockResolvedValueOnce([
|
||||
{ id: 1, scholar_id: "abc", display_name: "Test", profile_image_url: null, profile_image_source: "none" as const, is_enabled: true, baseline_completed: true, last_run_dt: null, last_run_status: null },
|
||||
]);
|
||||
const data = usePublicationData();
|
||||
await data.loadScholarFilters();
|
||||
expect(mockedListScholars).toHaveBeenCalled();
|
||||
expect(data.scholars.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("loadPublications calls listPublications with current filters", async () => {
|
||||
mockedListPublications.mockResolvedValueOnce({
|
||||
publications: [],
|
||||
mode: "all" as const,
|
||||
favorite_only: false,
|
||||
selected_scholar_profile_id: null,
|
||||
unread_count: 0,
|
||||
favorites_count: 0,
|
||||
latest_count: 0,
|
||||
new_count: 0,
|
||||
total_count: 0,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
snapshot: "",
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
});
|
||||
const data = usePublicationData();
|
||||
await data.loadPublications();
|
||||
expect(mockedListPublications).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resetPageAndSnapshot resets page to 1", () => {
|
||||
const data = usePublicationData();
|
||||
data.currentPage.value = 5;
|
||||
data.resetPageAndSnapshot();
|
||||
expect(data.currentPage.value).toBe(1);
|
||||
});
|
||||
|
||||
it("toggleSort reverses direction for same key", async () => {
|
||||
mockedListPublications.mockResolvedValue({
|
||||
publications: [],
|
||||
mode: "all" as const,
|
||||
favorite_only: false,
|
||||
selected_scholar_profile_id: null,
|
||||
unread_count: 0,
|
||||
favorites_count: 0,
|
||||
latest_count: 0,
|
||||
new_count: 0,
|
||||
total_count: 0,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
snapshot: "",
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
});
|
||||
const data = usePublicationData();
|
||||
const initialDirection = data.sortDirection.value;
|
||||
await data.toggleSort(data.sortKey.value);
|
||||
expect(data.sortDirection.value).toBe(initialDirection === "asc" ? "desc" : "asc");
|
||||
});
|
||||
|
||||
it("sortMarker returns arrow indicators", () => {
|
||||
const data = usePublicationData();
|
||||
expect(data.sortMarker(data.sortKey.value)).toMatch(/[↑↓]/);
|
||||
expect(data.sortMarker("year" as never)).toBe("↕");
|
||||
});
|
||||
|
||||
it("computed pagination properties work correctly", () => {
|
||||
const data = usePublicationData();
|
||||
expect(data.hasPrevPage.value).toBe(false);
|
||||
expect(data.totalPages.value).toBe(1);
|
||||
expect(data.totalCount.value).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,338 @@
|
|||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
listPublications,
|
||||
type PublicationItem,
|
||||
type PublicationMode,
|
||||
type PublicationsResult,
|
||||
} from "@/features/publications";
|
||||
import { listScholars, type ScholarProfile } from "@/features/scholars";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
import { useRunStatusStore } from "@/stores/run_status";
|
||||
|
||||
export type PublicationSortKey =
|
||||
| "title"
|
||||
| "scholar"
|
||||
| "year"
|
||||
| "citations"
|
||||
| "first_seen"
|
||||
| "pdf_status";
|
||||
|
||||
export function publicationKey(item: PublicationItem): string {
|
||||
return `${item.scholar_profile_id}:${item.publication_id}`;
|
||||
}
|
||||
|
||||
export function usePublicationData() {
|
||||
const loading = ref(true);
|
||||
const mode = ref<PublicationMode>("all");
|
||||
const favoriteOnly = ref(false);
|
||||
const selectedScholarFilter = ref("");
|
||||
const searchQuery = ref("");
|
||||
const sortKey = ref<PublicationSortKey>("first_seen");
|
||||
const sortDirection = ref<"asc" | "desc">("desc");
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref("50");
|
||||
const publicationSnapshot = ref<string | null>(null);
|
||||
|
||||
const scholars = ref<ScholarProfile[]>([]);
|
||||
const listState = ref<PublicationsResult | null>(null);
|
||||
|
||||
const errorMessage = ref<string | null>(null);
|
||||
const errorRequestId = ref<string | null>(null);
|
||||
const successMessage = ref<string | null>(null);
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const textCollator = new Intl.Collator(undefined, { sensitivity: "base", numeric: true });
|
||||
const runStatus = useRunStatusStore();
|
||||
|
||||
// --- Route sync ---
|
||||
|
||||
function normalizeScholarFilterQuery(value: unknown): string {
|
||||
if (Array.isArray(value)) return normalizeScholarFilterQuery(value[0]);
|
||||
if (typeof value !== "string") return "";
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? String(parsed) : "";
|
||||
}
|
||||
|
||||
function normalizeFavoriteOnlyQuery(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return normalizeFavoriteOnlyQuery(value[0]);
|
||||
if (typeof value !== "string") return false;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes";
|
||||
}
|
||||
|
||||
function normalizePageQuery(value: unknown): number {
|
||||
if (Array.isArray(value)) return normalizePageQuery(value[0]);
|
||||
if (typeof value !== "string") return 1;
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : 1;
|
||||
}
|
||||
|
||||
function syncFiltersFromRoute(): boolean {
|
||||
let changed = false;
|
||||
const nextScholar = normalizeScholarFilterQuery(route.query.scholar);
|
||||
const nextFavoriteOnly = normalizeFavoriteOnlyQuery(route.query.favorite);
|
||||
const nextPage = normalizePageQuery(route.query.page);
|
||||
if (selectedScholarFilter.value !== nextScholar) { selectedScholarFilter.value = nextScholar; changed = true; }
|
||||
if (favoriteOnly.value !== nextFavoriteOnly) { favoriteOnly.value = nextFavoriteOnly; changed = true; }
|
||||
if (currentPage.value !== nextPage) { currentPage.value = nextPage; changed = true; }
|
||||
return changed;
|
||||
}
|
||||
|
||||
async function syncFiltersToRoute(): Promise<void> {
|
||||
const nextScholar = selectedScholarFilter.value.trim();
|
||||
const currentScholar = normalizeScholarFilterQuery(route.query.scholar);
|
||||
const currentFavoriteOnly = normalizeFavoriteOnlyQuery(route.query.favorite);
|
||||
const currentPageQuery = normalizePageQuery(route.query.page);
|
||||
if (
|
||||
nextScholar === currentScholar
|
||||
&& favoriteOnly.value === currentFavoriteOnly
|
||||
&& currentPage.value === currentPageQuery
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
scholar: nextScholar || undefined,
|
||||
favorite: favoriteOnly.value ? "1" : undefined,
|
||||
page: currentPage.value > 1 ? String(currentPage.value) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Sorting helpers ---
|
||||
|
||||
function publicationSortValue(item: PublicationItem, key: PublicationSortKey): number | string {
|
||||
if (key === "title") return item.title;
|
||||
if (key === "scholar") return item.scholar_label;
|
||||
if (key === "year") return item.year ?? -1;
|
||||
if (key === "citations") return item.citation_count;
|
||||
if (key === "pdf_status") {
|
||||
if (item.pdf_url || item.pdf_status === "resolved") return 4;
|
||||
if (item.pdf_status === "running") return 3;
|
||||
if (item.pdf_status === "queued") return 2;
|
||||
if (item.pdf_status === "failed") return 0;
|
||||
return 1;
|
||||
}
|
||||
const timestamp = Date.parse(item.first_seen_at);
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
// --- Computed ---
|
||||
|
||||
const selectedScholarName = computed(() => {
|
||||
const selectedId = Number(selectedScholarFilter.value);
|
||||
if (!Number.isInteger(selectedId) || selectedId <= 0) return "all scholars";
|
||||
const profile = scholars.value.find((item) => item.id === selectedId);
|
||||
return profile ? (profile.display_name || profile.scholar_id) : "the selected scholar";
|
||||
});
|
||||
|
||||
const filteredPublications = computed(() => {
|
||||
let stream = [...runStatus.livePublications];
|
||||
if (favoriteOnly.value) stream = stream.filter((p) => p.is_favorite);
|
||||
if (mode.value === "unread") stream = stream.filter((p) => !p.is_read);
|
||||
const selectedScholarId = Number(selectedScholarFilter.value);
|
||||
if (Number.isInteger(selectedScholarId) && selectedScholarId > 0) {
|
||||
stream = stream.filter((p) => p.scholar_profile_id === selectedScholarId);
|
||||
}
|
||||
const base = listState.value?.publications ?? [];
|
||||
const merged = [...stream, ...base];
|
||||
const seenKeys = new Set<string>();
|
||||
const deduped: typeof base = [];
|
||||
for (const item of merged) {
|
||||
const key = publicationKey(item);
|
||||
if (!seenKeys.has(key)) { seenKeys.add(key); deduped.push(item); }
|
||||
}
|
||||
return deduped;
|
||||
});
|
||||
|
||||
const sortedPublications = computed(() => {
|
||||
const direction = sortDirection.value === "asc" ? 1 : -1;
|
||||
return [...filteredPublications.value].sort((left, right) => {
|
||||
const leftValue = publicationSortValue(left, sortKey.value);
|
||||
const rightValue = publicationSortValue(right, sortKey.value);
|
||||
let comparison = 0;
|
||||
if (typeof leftValue === "string" && typeof rightValue === "string") {
|
||||
comparison = textCollator.compare(leftValue, rightValue);
|
||||
} else {
|
||||
comparison = Number(leftValue) - Number(rightValue);
|
||||
}
|
||||
if (comparison !== 0) return comparison * direction;
|
||||
return right.publication_id - left.publication_id;
|
||||
});
|
||||
});
|
||||
|
||||
const visibleUnreadKeys = computed(() => {
|
||||
const keys = new Set<string>();
|
||||
for (const item of sortedPublications.value) {
|
||||
if (!item.is_read) keys.add(publicationKey(item));
|
||||
}
|
||||
return keys;
|
||||
});
|
||||
|
||||
const pageSizeValue = computed(() => {
|
||||
const parsed = Number(pageSize.value);
|
||||
if (!Number.isInteger(parsed)) return 50;
|
||||
return Math.max(10, Math.min(200, parsed));
|
||||
});
|
||||
const hasNextPage = computed(() => Boolean(listState.value?.has_next));
|
||||
const hasPrevPage = computed(() => Boolean(listState.value?.has_prev));
|
||||
const totalPages = computed(() => {
|
||||
if (!listState.value) return 1;
|
||||
return Math.max(1, Math.ceil(listState.value.total_count / Math.max(listState.value.page_size, 1)));
|
||||
});
|
||||
const totalCount = computed(() => listState.value?.total_count ?? 0);
|
||||
const visibleCount = computed(() => sortedPublications.value.length);
|
||||
const visibleUnreadCount = computed(() => visibleUnreadKeys.value.size);
|
||||
const visibleFavoriteCount = computed(
|
||||
() => sortedPublications.value.filter((item) => item.is_favorite).length,
|
||||
);
|
||||
|
||||
// --- Data loading ---
|
||||
|
||||
async function loadScholarFilters(): Promise<void> {
|
||||
try { scholars.value = await listScholars(); } catch { scholars.value = []; }
|
||||
}
|
||||
|
||||
function selectedScholarId(): number | undefined {
|
||||
const parsed = Number(selectedScholarFilter.value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
async function loadPublications(): Promise<void> {
|
||||
loading.value = true;
|
||||
errorMessage.value = null;
|
||||
errorRequestId.value = null;
|
||||
try {
|
||||
listState.value = await listPublications({
|
||||
mode: mode.value,
|
||||
favoriteOnly: favoriteOnly.value,
|
||||
scholarProfileId: selectedScholarId(),
|
||||
search: searchQuery.value.trim() || undefined,
|
||||
sortBy: sortKey.value,
|
||||
sortDir: sortDirection.value,
|
||||
page: currentPage.value,
|
||||
pageSize: pageSizeValue.value,
|
||||
snapshot: publicationSnapshot.value ?? undefined,
|
||||
});
|
||||
publicationSnapshot.value = listState.value.snapshot;
|
||||
currentPage.value = listState.value.page;
|
||||
pageSize.value = String(listState.value.page_size);
|
||||
} catch (error) {
|
||||
listState.value = null;
|
||||
if (error instanceof ApiRequestError) {
|
||||
errorMessage.value = error.message;
|
||||
errorRequestId.value = error.requestId;
|
||||
} else {
|
||||
errorMessage.value = "Unable to load publications.";
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetPageAndSnapshot(): void {
|
||||
currentPage.value = 1;
|
||||
publicationSnapshot.value = null;
|
||||
}
|
||||
|
||||
async function toggleSort(nextKey: PublicationSortKey): Promise<void> {
|
||||
if (sortKey.value === nextKey) {
|
||||
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
sortKey.value = nextKey;
|
||||
sortDirection.value = nextKey === "first_seen" || nextKey === "pdf_status" ? "desc" : "asc";
|
||||
}
|
||||
resetPageAndSnapshot();
|
||||
await loadPublications();
|
||||
}
|
||||
|
||||
function sortMarker(key: PublicationSortKey): string {
|
||||
if (sortKey.value !== key) return "↕";
|
||||
return sortDirection.value === "asc" ? "↑" : "↓";
|
||||
}
|
||||
|
||||
function replacePublication(updated: PublicationItem): void {
|
||||
if (!listState.value) return;
|
||||
listState.value.publications = listState.value.publications.map((item) =>
|
||||
publicationKey(item) !== publicationKey(updated) ? item : updated,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Search debounce watcher setup ---
|
||||
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
watch(searchQuery, () => {
|
||||
if (searchDebounceTimer !== null) clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
resetPageAndSnapshot();
|
||||
void loadPublications();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// --- Run-triggered refresh watcher ---
|
||||
|
||||
watch(
|
||||
() => runStatus.latestRun,
|
||||
async (nextRun, previousRun) => {
|
||||
const nextRunId = nextRun && (nextRun.status === "running" || nextRun.status === "resolving") ? nextRun.id : null;
|
||||
const previousRunId = previousRun && (previousRun.status === "running" || previousRun.status === "resolving") ? previousRun.id : null;
|
||||
if (nextRunId === null || nextRunId === previousRunId) return;
|
||||
resetPageAndSnapshot();
|
||||
await loadPublications();
|
||||
},
|
||||
);
|
||||
|
||||
// --- Route watcher ---
|
||||
|
||||
watch(
|
||||
() => [route.query.scholar, route.query.favorite, route.query.page],
|
||||
async () => {
|
||||
const previousScholar = selectedScholarFilter.value;
|
||||
const previousFavorite = favoriteOnly.value;
|
||||
const changed = syncFiltersFromRoute();
|
||||
if (!changed) return;
|
||||
if (selectedScholarFilter.value !== previousScholar || favoriteOnly.value !== previousFavorite) {
|
||||
publicationSnapshot.value = null;
|
||||
}
|
||||
await loadPublications();
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
loading,
|
||||
mode,
|
||||
favoriteOnly,
|
||||
selectedScholarFilter,
|
||||
searchQuery,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
currentPage,
|
||||
pageSize,
|
||||
scholars,
|
||||
listState,
|
||||
errorMessage,
|
||||
errorRequestId,
|
||||
successMessage,
|
||||
selectedScholarName,
|
||||
sortedPublications,
|
||||
visibleUnreadKeys,
|
||||
pageSizeValue,
|
||||
hasNextPage,
|
||||
hasPrevPage,
|
||||
totalPages,
|
||||
totalCount,
|
||||
visibleCount,
|
||||
visibleUnreadCount,
|
||||
visibleFavoriteCount,
|
||||
loadScholarFilters,
|
||||
loadPublications,
|
||||
resetPageAndSnapshot,
|
||||
toggleSort,
|
||||
sortMarker,
|
||||
replacePublication,
|
||||
syncFiltersFromRoute,
|
||||
syncFiltersToRoute,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ScholarBatchAdd from "./ScholarBatchAdd.vue";
|
||||
|
||||
const defaultProps = { saving: false, loading: false };
|
||||
|
||||
describe("ScholarBatchAdd", () => {
|
||||
it("renders the heading and input", () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||
expect(wrapper.text()).toContain("Add Scholar Profiles");
|
||||
expect(wrapper.find("textarea").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows parsed ID count as 0 for empty input", () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||
expect(wrapper.text()).toContain("Parsed IDs:");
|
||||
});
|
||||
|
||||
it("parses bare scholar IDs from textarea input", async () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||
await wrapper.find("textarea").setValue("A-UbBTPM15wL");
|
||||
expect(wrapper.text()).toContain("1");
|
||||
});
|
||||
|
||||
it("parses scholar IDs from Google Scholar URLs", async () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||
await wrapper.find("textarea").setValue(
|
||||
"https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL",
|
||||
);
|
||||
expect(wrapper.text()).toContain("1");
|
||||
});
|
||||
|
||||
it("deduplicates IDs from mixed input", async () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||
await wrapper.find("textarea").setValue(
|
||||
"A-UbBTPM15wL\nhttps://scholar.google.com/citations?user=A-UbBTPM15wL",
|
||||
);
|
||||
expect(wrapper.text()).toContain("1");
|
||||
});
|
||||
|
||||
it("parses multiple IDs separated by commas", async () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||
await wrapper.find("textarea").setValue("A-UbBTPM15wL, B-UbBTPM15wL");
|
||||
expect(wrapper.text()).toContain("2");
|
||||
});
|
||||
|
||||
it("emits add-scholars with parsed IDs on submit", async () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||
await wrapper.find("textarea").setValue("A-UbBTPM15wL");
|
||||
await wrapper.find("form").trigger("submit");
|
||||
const emitted = wrapper.emitted("add-scholars");
|
||||
expect(emitted).toHaveLength(1);
|
||||
expect(emitted![0][0]).toEqual(["A-UbBTPM15wL"]);
|
||||
});
|
||||
|
||||
it("clears textarea after successful submit", async () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||
const textarea = wrapper.find("textarea");
|
||||
await textarea.setValue("A-UbBTPM15wL");
|
||||
await wrapper.find("form").trigger("submit");
|
||||
expect((textarea.element as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("does not emit when input contains no valid IDs", async () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||
await wrapper.find("textarea").setValue("not-a-valid-id");
|
||||
await wrapper.find("form").trigger("submit");
|
||||
expect(wrapper.emitted("add-scholars")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows Adding... label when saving prop is true", () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: { ...defaultProps, saving: true } });
|
||||
expect(wrapper.text()).toContain("Adding...");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppCard from "@/components/ui/AppCard.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
|
||||
defineProps<{
|
||||
saving: boolean;
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "add-scholars", ids: string[]): void;
|
||||
}>();
|
||||
|
||||
const scholarBatchInput = ref("");
|
||||
|
||||
const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/;
|
||||
const URL_USER_PARAM_PATTERN = /(?:\?|&)user=([a-zA-Z0-9_-]{12})(?:&|#|$)/i;
|
||||
|
||||
function parseScholarIds(raw: string): string[] {
|
||||
const ordered: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const tokens = raw.split(/[\s,;]+/).map((v) => v.trim()).filter((v) => v.length > 0);
|
||||
for (const token of tokens) {
|
||||
let candidate: string | null = null;
|
||||
if (SCHOLAR_ID_PATTERN.test(token)) candidate = token;
|
||||
if (!candidate) {
|
||||
const directParamMatch = token.match(URL_USER_PARAM_PATTERN);
|
||||
if (directParamMatch) candidate = directParamMatch[1];
|
||||
}
|
||||
if (!candidate && token.includes("scholar.google")) {
|
||||
try {
|
||||
const parsed = new URL(token);
|
||||
const userParam = parsed.searchParams.get("user");
|
||||
if (userParam && SCHOLAR_ID_PATTERN.test(userParam)) candidate = userParam;
|
||||
} catch (_error) { /* Ignore non-URL tokens. */ }
|
||||
}
|
||||
if (!candidate || seen.has(candidate)) continue;
|
||||
seen.add(candidate);
|
||||
ordered.push(candidate);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
const parsedBatchCount = computed(() => parseScholarIds(scholarBatchInput.value).length);
|
||||
|
||||
function onSubmit(): void {
|
||||
const ids = parseScholarIds(scholarBatchInput.value);
|
||||
if (ids.length > 0) {
|
||||
emit("add-scholars", ids);
|
||||
scholarBatchInput.value = "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppCard class="space-y-4 xl:flex xl:min-h-0 xl:flex-col">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Add Scholar Profiles</h2>
|
||||
<AppHelpHint text="A scholar profile is a Google Scholar author page that Scholarr will monitor for publication changes." />
|
||||
</div>
|
||||
<p class="text-sm text-secondary">Paste one or more Scholar IDs or profile URLs and add them in one action.</p>
|
||||
</div>
|
||||
|
||||
<form class="grid gap-3" @submit.prevent="onSubmit">
|
||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary" for="scholar-batch-input">
|
||||
<span>Scholar IDs or profile URLs</span>
|
||||
<textarea
|
||||
id="scholar-batch-input"
|
||||
v-model="scholarBatchInput"
|
||||
rows="5"
|
||||
placeholder="A-UbBTPM15wL https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL"
|
||||
class="w-full rounded-lg border border-stroke-interactive bg-surface-input px-3 py-2 text-sm text-ink-primary outline-none ring-focus-ring transition placeholder:text-ink-muted focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="text-xs text-secondary">
|
||||
Parsed IDs: <strong class="text-ink-primary">{{ parsedBatchCount }}</strong>
|
||||
</p>
|
||||
<AppButton type="submit" :disabled="saving || loading">
|
||||
{{ saving ? "Adding..." : "Add scholars" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</form>
|
||||
</AppCard>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ScholarNameSearch from "./ScholarNameSearch.vue";
|
||||
|
||||
const defaultProps = {
|
||||
trackedScholarIds: new Set<string>(),
|
||||
addingCandidateScholarId: null,
|
||||
};
|
||||
|
||||
describe("ScholarNameSearch", () => {
|
||||
it("renders the search heading", () => {
|
||||
const wrapper = mount(ScholarNameSearch, {
|
||||
props: defaultProps,
|
||||
global: { stubs: { ScholarAvatar: true } },
|
||||
});
|
||||
expect(wrapper.text()).toContain("Search by Name");
|
||||
});
|
||||
|
||||
it("shows WIP badge indicating the feature is incomplete", () => {
|
||||
const wrapper = mount(ScholarNameSearch, {
|
||||
props: defaultProps,
|
||||
global: { stubs: { ScholarAvatar: true } },
|
||||
});
|
||||
expect(wrapper.text()).toContain("WIP");
|
||||
});
|
||||
|
||||
it("renders the search input", () => {
|
||||
const wrapper = mount(ScholarNameSearch, {
|
||||
props: defaultProps,
|
||||
global: { stubs: { ScholarAvatar: true } },
|
||||
});
|
||||
expect(wrapper.find("form").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows warning about Google Scholar login challenges", () => {
|
||||
const wrapper = mount(ScholarNameSearch, {
|
||||
props: defaultProps,
|
||||
global: { stubs: { ScholarAvatar: true } },
|
||||
});
|
||||
expect(wrapper.text()).toContain("login challenge");
|
||||
});
|
||||
});
|
||||
167
frontend/src/features/scholars/components/ScholarNameSearch.vue
Normal file
167
frontend/src/features/scholars/components/ScholarNameSearch.vue
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
||||
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
||||
import AppAlert from "@/components/ui/AppAlert.vue";
|
||||
import AppBadge from "@/components/ui/AppBadge.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppCard from "@/components/ui/AppCard.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import ScholarAvatar from "@/features/scholars/components/ScholarAvatar.vue";
|
||||
import type { ScholarSearchCandidate, ScholarSearchResult } from "@/features/scholars";
|
||||
|
||||
defineProps<{
|
||||
trackedScholarIds: Set<string>;
|
||||
addingCandidateScholarId: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "add-candidate", candidate: ScholarSearchCandidate): void;
|
||||
}>();
|
||||
|
||||
const nameSearchWip = true;
|
||||
const searchingByName = ref(false);
|
||||
const searchQuery = ref("");
|
||||
const searchResult = ref<ScholarSearchResult | null>(null);
|
||||
const searchErrorMessage = ref<string | null>(null);
|
||||
const searchErrorRequestId = ref<string | null>(null);
|
||||
|
||||
const searchHasRun = computed(() => searchResult.value !== null);
|
||||
const searchIsDegraded = computed(() => {
|
||||
if (!searchResult.value) return false;
|
||||
return searchResult.value.state === "blocked_or_captcha" || searchResult.value.state === "network_error";
|
||||
});
|
||||
const searchStateTone = computed(() => {
|
||||
const result = searchResult.value;
|
||||
if (!result) return "neutral" as const;
|
||||
if (result.state === "ok") return "success" as const;
|
||||
if (result.state === "blocked_or_captcha" || result.state === "network_error") return "warning" as const;
|
||||
return "neutral" as const;
|
||||
});
|
||||
const emptySearchCandidates = computed(() => {
|
||||
const result = searchResult.value;
|
||||
if (!result) return false;
|
||||
return result.candidates.length === 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppCard class="relative space-y-4 select-none xl:flex xl:min-h-0 xl:flex-col xl:overflow-hidden">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Search by Name</h2>
|
||||
<AppHelpHint text="Google Scholar now challenges this endpoint with account login and anti-bot checks, so this workflow stays disabled." />
|
||||
</div>
|
||||
<p class="text-sm text-secondary">
|
||||
This helper remains paused because Google Scholar currently requires account login for reliable name search access.
|
||||
</p>
|
||||
</div>
|
||||
<AppHelpHint text="Name search is kept as WIP. Use direct Scholar ID or profile URL adds for production tracking.">
|
||||
<template #trigger>
|
||||
<AppBadge tone="warning">WIP</AppBadge>
|
||||
</template>
|
||||
</AppHelpHint>
|
||||
</div>
|
||||
|
||||
<form class="pointer-events-none flex flex-wrap items-center gap-2 opacity-60">
|
||||
<div class="min-w-0 flex-1">
|
||||
<AppInput
|
||||
id="scholar-search-name"
|
||||
v-model="searchQuery"
|
||||
placeholder="e.g. Geoffrey Hinton"
|
||||
:disabled="nameSearchWip"
|
||||
/>
|
||||
</div>
|
||||
<AppButton type="button" :disabled="nameSearchWip">
|
||||
Search
|
||||
</AppButton>
|
||||
</form>
|
||||
<p class="text-xs text-secondary">
|
||||
Direct Scholar ID/URL adds above are the supported path until name search can run without login challenges.
|
||||
</p>
|
||||
|
||||
<div class="min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
|
||||
<RequestStateAlerts
|
||||
:error-message="searchErrorMessage"
|
||||
:error-request-id="searchErrorRequestId"
|
||||
error-title="Search request failed"
|
||||
/>
|
||||
|
||||
<AsyncStateGate :loading="searchingByName" :loading-lines="4" :show-empty="false">
|
||||
<template v-if="searchResult">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="text-sm text-secondary">
|
||||
{{ searchResult.candidates.length }} candidate{{ searchResult.candidates.length === 1 ? "" : "s" }}
|
||||
for <strong class="text-ink-primary">{{ searchResult.query }}</strong>
|
||||
</p>
|
||||
<AppBadge :tone="searchStateTone">{{ searchResult.state }}</AppBadge>
|
||||
</div>
|
||||
|
||||
<p v-if="searchResult.state !== 'ok' || searchResult.warnings.length > 0" class="text-xs text-secondary">
|
||||
<span>State reason: <code>{{ searchResult.state_reason }}</code></span>
|
||||
<span v-if="searchResult.action_hint">. {{ searchResult.action_hint }}</span>
|
||||
<span v-if="searchResult.warnings.length > 0">. Warnings: {{ searchResult.warnings.join(", ") }}</span>
|
||||
</p>
|
||||
|
||||
<AppAlert v-if="searchIsDegraded" tone="warning">
|
||||
<template #title>Name search is degraded</template>
|
||||
<p>This endpoint throttles aggressively to avoid blocks. Use Scholar URL/ID adds for dependable tracking.</p>
|
||||
</AppAlert>
|
||||
|
||||
<AsyncStateGate
|
||||
:loading="false"
|
||||
:show-empty="true"
|
||||
:empty="emptySearchCandidates"
|
||||
empty-title="No scholar matches returned"
|
||||
empty-body="Try another query later or add directly by Scholar URL/ID."
|
||||
>
|
||||
<ul class="grid gap-3">
|
||||
<li
|
||||
v-for="candidate in searchResult.candidates"
|
||||
:key="candidate.scholar_id"
|
||||
class="rounded-xl border border-stroke-default bg-surface-card-muted/70 p-3"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<ScholarAvatar
|
||||
size="sm"
|
||||
:label="candidate.display_name"
|
||||
:scholar-id="candidate.scholar_id"
|
||||
:image-url="candidate.profile_image_url"
|
||||
/>
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<strong class="text-sm text-ink-primary">{{ candidate.display_name }}</strong>
|
||||
<code class="text-xs text-secondary">{{ candidate.scholar_id }}</code>
|
||||
<AppBadge v-if="trackedScholarIds.has(candidate.scholar_id)" tone="success">Tracked</AppBadge>
|
||||
</div>
|
||||
<p class="truncate text-xs text-secondary">{{ candidate.affiliation || "No affiliation provided" }}</p>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-secondary">
|
||||
<span v-if="candidate.email_domain">Email: {{ candidate.email_domain }}</span>
|
||||
<span v-if="candidate.cited_by_count !== null">Cited by: {{ candidate.cited_by_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid shrink-0 gap-2">
|
||||
<AppButton
|
||||
:disabled="trackedScholarIds.has(candidate.scholar_id) || addingCandidateScholarId === candidate.scholar_id"
|
||||
@click="emit('add-candidate', candidate)"
|
||||
>
|
||||
{{ addingCandidateScholarId === candidate.scholar_id ? "Adding..." : "Add" }}
|
||||
</AppButton>
|
||||
<a :href="candidate.profile_url" target="_blank" rel="noreferrer" class="link-inline text-xs text-center">
|
||||
Open profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</AsyncStateGate>
|
||||
</template>
|
||||
<p v-else-if="!searchErrorMessage && !searchHasRun" class="text-sm text-secondary">
|
||||
Name search remains disabled while login-gated responses are unresolved.
|
||||
</p>
|
||||
</AsyncStateGate>
|
||||
</div>
|
||||
</AppCard>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ScholarSettingsModal from "./ScholarSettingsModal.vue";
|
||||
import type { ScholarProfile } from "@/features/scholars";
|
||||
|
||||
function buildScholar(overrides: Partial<ScholarProfile> = {}): ScholarProfile {
|
||||
return {
|
||||
id: 1,
|
||||
scholar_id: "abcDEF123456",
|
||||
display_name: "Dr. Test Scholar",
|
||||
profile_image_url: null,
|
||||
profile_image_source: "none" as const,
|
||||
is_enabled: true,
|
||||
baseline_completed: true,
|
||||
last_run_dt: null,
|
||||
last_run_status: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
scholar: buildScholar(),
|
||||
imageUrlDraft: "",
|
||||
imageBusy: false,
|
||||
imageSaving: false,
|
||||
imageUploading: false,
|
||||
saving: false,
|
||||
};
|
||||
|
||||
describe("ScholarSettingsModal", () => {
|
||||
it("renders scholar name and ID when scholar is provided", () => {
|
||||
const wrapper = mount(ScholarSettingsModal, {
|
||||
props: defaultProps,
|
||||
global: { stubs: { AppModal: false, RouterLink: true, ScholarAvatar: true } },
|
||||
});
|
||||
expect(wrapper.text()).toContain("Dr. Test Scholar");
|
||||
expect(wrapper.text()).toContain("abcDEF123456");
|
||||
});
|
||||
|
||||
it("renders nothing when scholar is null", () => {
|
||||
const wrapper = mount(ScholarSettingsModal, {
|
||||
props: { ...defaultProps, scholar: null },
|
||||
global: { stubs: { AppModal: false, RouterLink: true, ScholarAvatar: true } },
|
||||
});
|
||||
expect(wrapper.text()).not.toContain("Dr. Test Scholar");
|
||||
});
|
||||
|
||||
it("emits close when close event fires", async () => {
|
||||
const wrapper = mount(ScholarSettingsModal, {
|
||||
props: defaultProps,
|
||||
global: { stubs: { RouterLink: true, ScholarAvatar: true } },
|
||||
});
|
||||
const modal = wrapper.findComponent({ name: "AppModal" });
|
||||
if (modal.exists()) {
|
||||
modal.vm.$emit("close");
|
||||
expect(wrapper.emitted("close")).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("emits delete when delete button is clicked", async () => {
|
||||
const wrapper = mount(ScholarSettingsModal, {
|
||||
props: defaultProps,
|
||||
global: { stubs: { RouterLink: true, ScholarAvatar: true } },
|
||||
});
|
||||
const deleteButton = wrapper.findAll("button").find((b) => b.text().includes("Delete"));
|
||||
if (deleteButton) {
|
||||
await deleteButton.trigger("click");
|
||||
expect(wrapper.emitted("delete")).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("emits toggle when enable/disable button is clicked", async () => {
|
||||
const wrapper = mount(ScholarSettingsModal, {
|
||||
props: defaultProps,
|
||||
global: { stubs: { RouterLink: true, ScholarAvatar: true } },
|
||||
});
|
||||
const toggleButton = wrapper.findAll("button").find((b) => b.text().includes("Disable"));
|
||||
if (toggleButton) {
|
||||
await toggleButton.trigger("click");
|
||||
expect(wrapper.emitted("toggle")).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows Enable button for disabled scholars", () => {
|
||||
const wrapper = mount(ScholarSettingsModal, {
|
||||
props: { ...defaultProps, scholar: buildScholar({ is_enabled: false }) },
|
||||
global: { stubs: { RouterLink: true, ScholarAvatar: true } },
|
||||
});
|
||||
expect(wrapper.text()).toContain("Enable");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
<script setup lang="ts">
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import AppModal from "@/components/ui/AppModal.vue";
|
||||
import ScholarAvatar from "@/features/scholars/components/ScholarAvatar.vue";
|
||||
import type { ScholarProfile } from "@/features/scholars";
|
||||
|
||||
const props = defineProps<{
|
||||
scholar: ScholarProfile | null;
|
||||
imageUrlDraft: string;
|
||||
imageBusy: boolean;
|
||||
imageSaving: boolean;
|
||||
imageUploading: boolean;
|
||||
saving: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
(e: "update:image-url-draft", value: string): void;
|
||||
(e: "save-image-url"): void;
|
||||
(e: "upload-image", event: Event): void;
|
||||
(e: "reset-image"): void;
|
||||
(e: "toggle"): void;
|
||||
(e: "delete"): void;
|
||||
}>();
|
||||
|
||||
function scholarLabel(profile: ScholarProfile): string {
|
||||
return profile.display_name || profile.scholar_id;
|
||||
}
|
||||
|
||||
function scholarProfileUrl(scholarId: string): string {
|
||||
return `https://scholar.google.com/citations?hl=en&user=${encodeURIComponent(scholarId)}`;
|
||||
}
|
||||
|
||||
function scholarPublicationsRoute(profile: ScholarProfile): { name: string; query: { scholar: string } } {
|
||||
return { name: "publications", query: { scholar: String(profile.id) } };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppModal :open="scholar !== null" title="Scholar settings" @close="emit('close')">
|
||||
<div v-if="scholar" class="grid gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<ScholarAvatar
|
||||
:label="scholar.display_name"
|
||||
:scholar-id="scholar.scholar_id"
|
||||
:image-url="scholar.profile_image_url"
|
||||
/>
|
||||
<div class="min-w-0 space-y-1">
|
||||
<p class="truncate text-sm font-semibold text-ink-primary">{{ scholarLabel(scholar) }}</p>
|
||||
<p class="text-xs text-secondary">ID: <code>{{ scholar.scholar_id }}</code></p>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<RouterLink :to="scholarPublicationsRoute(scholar)" class="link-inline text-xs">
|
||||
View publications
|
||||
</RouterLink>
|
||||
<a :href="scholarProfileUrl(scholar.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">
|
||||
Open Google Scholar profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label class="text-sm font-medium text-ink-secondary" :for="`scholar-image-url-${scholar.id}`">
|
||||
Profile image URL override
|
||||
</label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<AppInput
|
||||
:id="`scholar-image-url-${scholar.id}`"
|
||||
:model-value="imageUrlDraft"
|
||||
placeholder="https://example.com/avatar.jpg"
|
||||
:disabled="imageBusy"
|
||||
@update:model-value="emit('update:image-url-draft', $event as string)"
|
||||
/>
|
||||
</div>
|
||||
<AppButton variant="secondary" :disabled="imageBusy" @click="emit('save-image-url')">
|
||||
{{ imageSaving ? "Saving..." : "Save URL" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label
|
||||
:for="`scholar-image-upload-${scholar.id}`"
|
||||
class="inline-flex min-h-10 cursor-pointer items-center justify-center rounded-lg border border-stroke-strong bg-action-secondary-bg px-3 py-2 text-sm font-semibold text-action-secondary-text transition hover:bg-action-secondary-hover-bg focus-within:outline-none focus-within:ring-2 focus-within:ring-focus-ring focus-within:ring-offset-2 focus-within:ring-offset-focus-offset"
|
||||
:class="{ 'pointer-events-none opacity-60': imageBusy }"
|
||||
>
|
||||
{{ imageUploading ? "Uploading..." : "Upload image" }}
|
||||
</label>
|
||||
<input
|
||||
:id="`scholar-image-upload-${scholar.id}`"
|
||||
type="file"
|
||||
class="sr-only"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
:disabled="imageBusy"
|
||||
@change="emit('upload-image', $event)"
|
||||
/>
|
||||
<AppButton variant="ghost" :disabled="imageBusy" @click="emit('reset-image')">
|
||||
Reset image
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-stroke-default pt-3">
|
||||
<AppButton variant="secondary" :disabled="imageBusy || saving" @click="emit('toggle')">
|
||||
{{ scholar.is_enabled ? "Disable scholar" : "Enable scholar" }}
|
||||
</AppButton>
|
||||
<AppButton variant="danger" :disabled="imageBusy || saving" @click="emit('delete')">
|
||||
Delete scholar
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</AppModal>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,72 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import AdminIntegritySection from "./AdminIntegritySection.vue";
|
||||
|
||||
vi.mock("@/features/admin_dbops", () => ({
|
||||
getAdminDbIntegrityReport: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getAdminDbIntegrityReport, type AdminDbIntegrityReport } from "@/features/admin_dbops";
|
||||
|
||||
const mockedGetReport = vi.mocked(getAdminDbIntegrityReport);
|
||||
|
||||
function buildReport(overrides: Partial<AdminDbIntegrityReport> = {}): AdminDbIntegrityReport {
|
||||
return {
|
||||
status: "ok",
|
||||
warnings: [],
|
||||
failures: [],
|
||||
checked_at: "2025-01-15T12:00:00Z",
|
||||
checks: [
|
||||
{ name: "orphaned_publications", count: 0, severity: "metric", message: "No orphaned publications found." },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AdminIntegritySection", () => {
|
||||
beforeEach(() => {
|
||||
mockedGetReport.mockReset();
|
||||
});
|
||||
|
||||
it("renders the section heading", () => {
|
||||
const wrapper = mount(AdminIntegritySection);
|
||||
expect(wrapper.text()).toContain("Integrity Report");
|
||||
});
|
||||
|
||||
it("exposes load function that fetches integrity report", async () => {
|
||||
mockedGetReport.mockResolvedValueOnce(buildReport());
|
||||
const wrapper = mount(AdminIntegritySection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(mockedGetReport).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders check results after loading", async () => {
|
||||
mockedGetReport.mockResolvedValueOnce(buildReport());
|
||||
const wrapper = mount(AdminIntegritySection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("orphaned_publications");
|
||||
});
|
||||
|
||||
it("displays warning count when present", async () => {
|
||||
mockedGetReport.mockResolvedValueOnce(buildReport({ warnings: ["w1", "w2", "w3"] }));
|
||||
const wrapper = mount(AdminIntegritySection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("3");
|
||||
});
|
||||
|
||||
it("renders status badge", async () => {
|
||||
mockedGetReport.mockResolvedValueOnce(buildReport({ status: "failed" }));
|
||||
const wrapper = mount(AdminIntegritySection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("failed");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import AppBadge from "@/components/ui/AppBadge.vue";
|
||||
import AppCard from "@/components/ui/AppCard.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
|
||||
import AppTable from "@/components/ui/AppTable.vue";
|
||||
import {
|
||||
getAdminDbIntegrityReport,
|
||||
type AdminDbIntegrityCheck,
|
||||
type AdminDbIntegrityReport,
|
||||
} from "@/features/admin_dbops";
|
||||
|
||||
const refreshingIntegrity = ref(false);
|
||||
const integrityReport = ref<AdminDbIntegrityReport | null>(null);
|
||||
|
||||
function formatTimestamp(value: string | null): string {
|
||||
if (!value) return "n/a";
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function statusTone(status: string): "success" | "warning" | "danger" | "info" | "neutral" {
|
||||
if (status === "ok" || status === "completed" || status === "resolved") return "success";
|
||||
if (status === "warning" || status === "running" || status === "queued") return "warning";
|
||||
if (status === "failed") return "danger";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function checkTone(check: AdminDbIntegrityCheck): "warning" | "danger" | "neutral" | "info" {
|
||||
if (check.severity === "metric") return "info";
|
||||
if (check.count <= 0) return "neutral";
|
||||
return check.severity === "failure" ? "danger" : "warning";
|
||||
}
|
||||
|
||||
async function refreshIntegrity(): Promise<void> {
|
||||
refreshingIntegrity.value = true;
|
||||
try {
|
||||
integrityReport.value = await getAdminDbIntegrityReport();
|
||||
} finally {
|
||||
refreshingIntegrity.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
await refreshIntegrity();
|
||||
}
|
||||
|
||||
defineExpose({ load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppCard class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Integrity Report</h2>
|
||||
<AppHelpHint text="Read-only checks for known corruption patterns and data drift." />
|
||||
</div>
|
||||
<AppRefreshButton variant="secondary" :loading="refreshingIntegrity" title="Refresh integrity report" loading-title="Refreshing integrity report" @click="refreshIntegrity" />
|
||||
</div>
|
||||
|
||||
<div v-if="integrityReport" class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<AppBadge :tone="statusTone(integrityReport.status)">Status: {{ integrityReport.status }}</AppBadge>
|
||||
<AppBadge tone="warning">Warnings: {{ integrityReport.warnings.length }}</AppBadge>
|
||||
<AppBadge tone="danger">Failures: {{ integrityReport.failures.length }}</AppBadge>
|
||||
<span class="text-secondary">Checked: {{ formatTimestamp(integrityReport.checked_at) }}</span>
|
||||
</div>
|
||||
|
||||
<AppTable v-if="integrityReport" label="Integrity checks">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Check</th>
|
||||
<th scope="col">Count</th>
|
||||
<th scope="col">Severity</th>
|
||||
<th scope="col">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="check in integrityReport.checks" :key="check.name">
|
||||
<td>{{ check.name }}</td>
|
||||
<td>{{ check.count }}</td>
|
||||
<td><AppBadge :tone="checkTone(check)">{{ check.severity }}</AppBadge></td>
|
||||
<td>{{ check.message }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</AppTable>
|
||||
</AppCard>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import AdminPdfQueueSection from "./AdminPdfQueueSection.vue";
|
||||
|
||||
vi.mock("@/features/admin_dbops", () => ({
|
||||
listAdminPdfQueue: vi.fn(),
|
||||
requeueAdminPdfLookup: vi.fn(),
|
||||
requeueAllAdminPdfLookups: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listAdminPdfQueue } from "@/features/admin_dbops";
|
||||
|
||||
const mockedListQueue = vi.mocked(listAdminPdfQueue);
|
||||
|
||||
function buildQueueItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
publication_id: 1,
|
||||
title: "Test Paper",
|
||||
display_identifier: null,
|
||||
pdf_url: null,
|
||||
status: "queued",
|
||||
attempt_count: 0,
|
||||
last_failure_reason: null,
|
||||
last_failure_detail: null,
|
||||
last_source: "unpaywall",
|
||||
requested_by_user_id: null,
|
||||
requested_by_email: null,
|
||||
queued_at: "2025-01-15T12:00:00Z",
|
||||
last_attempt_at: null,
|
||||
resolved_at: null,
|
||||
updated_at: "2025-01-15T12:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AdminPdfQueueSection", () => {
|
||||
beforeEach(() => {
|
||||
mockedListQueue.mockReset();
|
||||
});
|
||||
|
||||
it("renders the PDF queue heading", () => {
|
||||
const wrapper = mount(AdminPdfQueueSection);
|
||||
expect(wrapper.text()).toContain("PDF");
|
||||
});
|
||||
|
||||
it("exposes load function that fetches PDF queue", async () => {
|
||||
mockedListQueue.mockResolvedValueOnce({
|
||||
items: [buildQueueItem()],
|
||||
total_count: 1,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
});
|
||||
const wrapper = mount(AdminPdfQueueSection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(mockedListQueue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders queue items after loading", async () => {
|
||||
mockedListQueue.mockResolvedValueOnce({
|
||||
items: [buildQueueItem({ title: "My Paper" })],
|
||||
total_count: 1,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
});
|
||||
const wrapper = mount(AdminPdfQueueSection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("My Paper");
|
||||
});
|
||||
|
||||
it("renders status filter dropdown", () => {
|
||||
const wrapper = mount(AdminPdfQueueSection);
|
||||
expect(wrapper.text()).toContain("Status");
|
||||
});
|
||||
|
||||
it("renders queue all button", () => {
|
||||
const wrapper = mount(AdminPdfQueueSection);
|
||||
expect(wrapper.text()).toContain("Queue all");
|
||||
});
|
||||
|
||||
it("shows pagination summary after loading", async () => {
|
||||
mockedListQueue.mockResolvedValueOnce({
|
||||
items: Array.from({ length: 3 }, (_, i) => buildQueueItem({ publication_id: i + 1 })),
|
||||
total_count: 3,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
});
|
||||
const wrapper = mount(AdminPdfQueueSection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("3");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import AppBadge from "@/components/ui/AppBadge.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppCard from "@/components/ui/AppCard.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
|
||||
import AppSelect from "@/components/ui/AppSelect.vue";
|
||||
import AppTable from "@/components/ui/AppTable.vue";
|
||||
import {
|
||||
listAdminPdfQueue,
|
||||
requeueAdminPdfLookup,
|
||||
requeueAllAdminPdfLookups,
|
||||
type AdminPdfQueueItem,
|
||||
} from "@/features/admin_dbops";
|
||||
import { useRequestState } from "@/composables/useRequestState";
|
||||
|
||||
const { clearAlerts, assignError, setSuccess } = useRequestState();
|
||||
|
||||
const refreshingPdfQueue = ref(false);
|
||||
const requeueingPublicationId = ref<number | null>(null);
|
||||
const requeueingAllPdfs = ref(false);
|
||||
const pdfQueueItems = ref<AdminPdfQueueItem[]>([]);
|
||||
const pdfQueueStatusFilter = ref("");
|
||||
const pdfQueuePage = ref(1);
|
||||
const pdfQueuePageSize = ref("50");
|
||||
const pdfQueueTotalCount = ref(0);
|
||||
const pdfQueueHasNext = ref(false);
|
||||
const pdfQueueHasPrev = ref(false);
|
||||
|
||||
const pdfQueuePageSizeValue = computed(() => {
|
||||
const parsed = Number(pdfQueuePageSize.value);
|
||||
if (!Number.isFinite(parsed)) return 50;
|
||||
return Math.max(1, Math.min(500, Math.trunc(parsed)));
|
||||
});
|
||||
const pdfQueueTotalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(pdfQueueTotalCount.value / Math.max(pdfQueuePageSizeValue.value, 1))),
|
||||
);
|
||||
const pdfQueueSummary = computed(() =>
|
||||
`${pdfQueueTotalCount.value} item${pdfQueueTotalCount.value === 1 ? "" : "s"} total`,
|
||||
);
|
||||
|
||||
function statusTone(status: string): "success" | "warning" | "danger" | "info" | "neutral" {
|
||||
if (status === "ok" || status === "completed" || status === "resolved") return "success";
|
||||
if (status === "warning" || status === "running" || status === "queued") return "warning";
|
||||
if (status === "failed") return "danger";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null): string {
|
||||
if (!value) return "n/a";
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function canRequeuePdf(item: AdminPdfQueueItem): boolean {
|
||||
return item.status !== "queued" && item.status !== "running";
|
||||
}
|
||||
|
||||
async function refreshPdfQueue(): Promise<void> {
|
||||
refreshingPdfQueue.value = true;
|
||||
try {
|
||||
const status = pdfQueueStatusFilter.value.trim() || null;
|
||||
const page = await listAdminPdfQueue(pdfQueuePage.value, pdfQueuePageSizeValue.value, status);
|
||||
pdfQueueItems.value = page.items;
|
||||
pdfQueueTotalCount.value = page.total_count;
|
||||
pdfQueueHasNext.value = page.has_next;
|
||||
pdfQueueHasPrev.value = page.has_prev;
|
||||
pdfQueuePage.value = page.page;
|
||||
pdfQueuePageSize.value = String(page.page_size);
|
||||
} finally {
|
||||
refreshingPdfQueue.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onPdfQueueFilterChanged(): Promise<void> {
|
||||
pdfQueuePage.value = 1;
|
||||
await refreshPdfQueue();
|
||||
}
|
||||
|
||||
async function onPdfQueuePageSizeChanged(): Promise<void> {
|
||||
pdfQueuePage.value = 1;
|
||||
await refreshPdfQueue();
|
||||
}
|
||||
|
||||
async function onPdfQueuePrevPage(): Promise<void> {
|
||||
if (!pdfQueueHasPrev.value || pdfQueuePage.value <= 1) return;
|
||||
pdfQueuePage.value = Math.max(pdfQueuePage.value - 1, 1);
|
||||
await refreshPdfQueue();
|
||||
}
|
||||
|
||||
async function onPdfQueueNextPage(): Promise<void> {
|
||||
if (!pdfQueueHasNext.value) return;
|
||||
pdfQueuePage.value += 1;
|
||||
await refreshPdfQueue();
|
||||
}
|
||||
|
||||
async function onRequeuePdf(item: AdminPdfQueueItem): Promise<void> {
|
||||
requeueingPublicationId.value = item.publication_id;
|
||||
clearAlerts();
|
||||
try {
|
||||
const result = await requeueAdminPdfLookup(item.publication_id);
|
||||
setSuccess(result.message);
|
||||
await refreshPdfQueue();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to requeue PDF lookup.");
|
||||
} finally {
|
||||
requeueingPublicationId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRequeueAllPdfs(): Promise<void> {
|
||||
requeueingAllPdfs.value = true;
|
||||
clearAlerts();
|
||||
try {
|
||||
const result = await requeueAllAdminPdfLookups(5000);
|
||||
setSuccess(result.message);
|
||||
await refreshPdfQueue();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to queue missing PDF lookups.");
|
||||
} finally {
|
||||
requeueingAllPdfs.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
await refreshPdfQueue();
|
||||
}
|
||||
|
||||
defineExpose({ load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppCard class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">PDF Gathering Queue</h2>
|
||||
<AppHelpHint text="Live queue and outcome history for PDF acquisition across all publications." />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<AppSelect v-model="pdfQueueStatusFilter" class="min-w-[12rem] !py-1.5 !text-sm" @change="onPdfQueueFilterChanged">
|
||||
<option value="">All statuses</option>
|
||||
<option value="untracked">untracked</option>
|
||||
<option value="queued">queued</option>
|
||||
<option value="running">running</option>
|
||||
<option value="failed">failed</option>
|
||||
<option value="resolved">resolved</option>
|
||||
</AppSelect>
|
||||
<AppSelect v-model="pdfQueuePageSize" class="min-w-[10rem] !py-1.5 !text-sm" @change="onPdfQueuePageSizeChanged">
|
||||
<option value="25">25 / page</option>
|
||||
<option value="50">50 / page</option>
|
||||
<option value="100">100 / page</option>
|
||||
</AppSelect>
|
||||
<AppButton variant="secondary" class="!min-h-8 whitespace-nowrap !px-2.5 !py-1 !text-xs" :disabled="requeueingAllPdfs" title="Queue all missing PDFs" @click="onRequeueAllPdfs">
|
||||
{{ requeueingAllPdfs ? "Queueing..." : "Queue all" }}
|
||||
</AppButton>
|
||||
<AppRefreshButton variant="secondary" size="sm" :loading="refreshingPdfQueue" title="Refresh PDF queue" loading-title="Refreshing PDF queue" @click="refreshPdfQueue" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppTable label="PDF queue table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Publication</th><th scope="col">Status</th><th scope="col">Attempts</th>
|
||||
<th scope="col">Failure reason</th><th scope="col">Source</th><th scope="col">Requested by</th>
|
||||
<th scope="col">Queued</th><th scope="col">Last attempt</th><th scope="col">Resolved</th><th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in pdfQueueItems" :key="item.publication_id">
|
||||
<td>
|
||||
<div class="grid gap-1">
|
||||
<span class="font-medium text-ink-primary">{{ item.title }}</span>
|
||||
<a v-if="item.display_identifier?.url" :href="item.display_identifier.url" target="_blank" rel="noreferrer" class="link-inline text-xs">{{ item.display_identifier.label }}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td><AppBadge :tone="statusTone(item.status)">{{ item.status }}</AppBadge></td>
|
||||
<td>{{ item.attempt_count }}</td>
|
||||
<td>{{ item.last_failure_reason || "n/a" }}</td>
|
||||
<td>{{ item.last_source || "n/a" }}</td>
|
||||
<td>{{ item.requested_by_email || "n/a" }}</td>
|
||||
<td>{{ formatTimestamp(item.queued_at) }}</td>
|
||||
<td>{{ formatTimestamp(item.last_attempt_at) }}</td>
|
||||
<td>{{ formatTimestamp(item.resolved_at) }}</td>
|
||||
<td>
|
||||
<AppButton variant="ghost" :disabled="requeueingPublicationId === item.publication_id || !canRequeuePdf(item)" @click="onRequeuePdf(item)">
|
||||
{{ requeueingPublicationId === item.publication_id ? "Requeueing..." : "Requeue" }}
|
||||
</AppButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</AppTable>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-secondary">
|
||||
<span>{{ pdfQueueSummary }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Page {{ pdfQueuePage }} / {{ pdfQueueTotalPages }}</span>
|
||||
<AppButton variant="ghost" :disabled="!pdfQueueHasPrev" @click="onPdfQueuePrevPage">Prev</AppButton>
|
||||
<AppButton variant="ghost" :disabled="!pdfQueueHasNext" @click="onPdfQueueNextPage">Next</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import AdminRepairsSection from "./AdminRepairsSection.vue";
|
||||
|
||||
vi.mock("@/features/admin_dbops", () => ({
|
||||
listAdminDbRepairJobs: vi.fn(),
|
||||
triggerPublicationLinkRepair: vi.fn(),
|
||||
triggerPublicationNearDuplicateRepair: vi.fn(),
|
||||
dropAllPublications: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listAdminDbRepairJobs, dropAllPublications } from "@/features/admin_dbops";
|
||||
|
||||
const mockedListJobs = vi.mocked(listAdminDbRepairJobs);
|
||||
const mockedDrop = vi.mocked(dropAllPublications);
|
||||
|
||||
function buildUser(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
email: "admin@test.com",
|
||||
is_admin: true,
|
||||
is_active: true,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AdminRepairsSection", () => {
|
||||
beforeEach(() => {
|
||||
mockedListJobs.mockReset();
|
||||
mockedDrop.mockReset();
|
||||
});
|
||||
|
||||
it("renders the repair sections", () => {
|
||||
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
|
||||
expect(wrapper.text()).toContain("Publication Link Repair");
|
||||
expect(wrapper.text()).toContain("Near-Duplicate");
|
||||
expect(wrapper.text()).toContain("Drop All Publications");
|
||||
});
|
||||
|
||||
it("exposes load function that fetches repair jobs", async () => {
|
||||
mockedListJobs.mockResolvedValueOnce([]);
|
||||
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(mockedListJobs).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders repair job rows after loading", async () => {
|
||||
mockedListJobs.mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
job_name: "publication_link_repair",
|
||||
status: "completed",
|
||||
dry_run: true,
|
||||
requested_by: "admin@test.com",
|
||||
scope: {},
|
||||
summary: { links_in_scope: 100, links_deleted: 5 },
|
||||
error_text: null,
|
||||
started_at: "2025-01-15T12:00:00Z",
|
||||
finished_at: "2025-01-15T12:01:00Z",
|
||||
created_at: "2025-01-15T12:00:00Z",
|
||||
updated_at: "2025-01-15T12:01:00Z",
|
||||
},
|
||||
]);
|
||||
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("publication_link_repair");
|
||||
});
|
||||
|
||||
it("has typed confirmation guard for drop all publications", () => {
|
||||
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
|
||||
const dropButton = wrapper.findAll("button").find((b) => b.text().includes("Drop all publications"));
|
||||
expect(dropButton?.attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders dry-run toggle in repair form", () => {
|
||||
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
|
||||
expect(wrapper.text()).toContain("Dry-run");
|
||||
});
|
||||
|
||||
it("renders scope mode selector", () => {
|
||||
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
|
||||
expect(wrapper.text()).toContain("Scope");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import AppBadge from "@/components/ui/AppBadge.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppCard from "@/components/ui/AppCard.vue";
|
||||
import AppCheckbox from "@/components/ui/AppCheckbox.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
|
||||
import AppSelect from "@/components/ui/AppSelect.vue";
|
||||
import AppTable from "@/components/ui/AppTable.vue";
|
||||
import {
|
||||
dropAllPublications,
|
||||
listAdminDbRepairJobs,
|
||||
triggerPublicationLinkRepair,
|
||||
triggerPublicationNearDuplicateRepair,
|
||||
type AdminDbRepairJob,
|
||||
type NearDuplicateCluster,
|
||||
type TriggerPublicationLinkRepairResult,
|
||||
type TriggerPublicationNearDuplicateRepairResult,
|
||||
} from "@/features/admin_dbops";
|
||||
import type { AdminUser } from "@/features/admin_users";
|
||||
import { useRequestState } from "@/composables/useRequestState";
|
||||
|
||||
const props = defineProps<{
|
||||
users: AdminUser[];
|
||||
}>();
|
||||
|
||||
const SCOPE_SINGLE_USER = "single_user";
|
||||
const SCOPE_ALL_USERS = "all_users";
|
||||
const APPLY_ALL_USERS_CONFIRM_TEXT = "REPAIR ALL USERS";
|
||||
const APPLY_NEAR_DUPLICATES_CONFIRM_TEXT = "MERGE SELECTED DUPLICATES";
|
||||
const DROP_PUBLICATIONS_CONFIRM_TEXT = "DROP ALL PUBLICATIONS";
|
||||
|
||||
type RepairScopeMode = typeof SCOPE_SINGLE_USER | typeof SCOPE_ALL_USERS;
|
||||
|
||||
const { clearAlerts, assignError, setSuccess } = useRequestState();
|
||||
|
||||
const refreshingJobs = ref(false);
|
||||
const runningRepair = ref(false);
|
||||
const repairJobs = ref<AdminDbRepairJob[]>([]);
|
||||
const lastRepairResult = ref<TriggerPublicationLinkRepairResult | null>(null);
|
||||
const repairScopeMode = ref<RepairScopeMode>(SCOPE_SINGLE_USER);
|
||||
const repairUserId = ref("");
|
||||
const repairScholarIds = ref("");
|
||||
const repairRequestedBy = ref("");
|
||||
const repairDryRun = ref(true);
|
||||
const repairGcOrphans = ref(false);
|
||||
const repairConfirmationText = ref("");
|
||||
|
||||
const runningNearDuplicateScan = ref(false);
|
||||
const applyingNearDuplicateRepair = ref(false);
|
||||
const nearDuplicateRequestedBy = ref("");
|
||||
const nearDuplicateSimilarityThreshold = ref("0.78");
|
||||
const nearDuplicateMinSharedTokens = ref("3");
|
||||
const nearDuplicateMaxYearDelta = ref("1");
|
||||
const nearDuplicateMaxClusters = ref("25");
|
||||
const nearDuplicateConfirmationText = ref("");
|
||||
const nearDuplicateSelectedClusterKeys = ref<Set<string>>(new Set());
|
||||
const nearDuplicateClusters = ref<NearDuplicateCluster[]>([]);
|
||||
const lastNearDuplicateResult = ref<TriggerPublicationNearDuplicateRepairResult | null>(null);
|
||||
|
||||
const droppingPublications = ref(false);
|
||||
const dropConfirmationText = ref("");
|
||||
const dropResult = ref<{ deleted_count: number; message: string } | null>(null);
|
||||
const dropConfirmationValid = computed(() => dropConfirmationText.value.trim() === DROP_PUBLICATIONS_CONFIRM_TEXT);
|
||||
|
||||
const typedConfirmationRequired = computed(
|
||||
() => repairScopeMode.value === SCOPE_ALL_USERS && !repairDryRun.value,
|
||||
);
|
||||
const nearDuplicateApplyEnabled = computed(() => nearDuplicateSelectedClusterKeys.value.size > 0);
|
||||
|
||||
function statusTone(status: string): "success" | "warning" | "danger" | "info" | "neutral" {
|
||||
if (status === "ok" || status === "completed" || status === "resolved") return "success";
|
||||
if (status === "warning" || status === "running" || status === "queued") return "warning";
|
||||
if (status === "failed") return "danger";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null): string {
|
||||
if (!value) return "n/a";
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function parseScholarIds(raw: string): number[] {
|
||||
const tokens = raw.split(/[,\s]+/).map((t) => t.trim()).filter((t) => t.length > 0);
|
||||
const deduped = new Set<number>();
|
||||
for (const token of tokens) {
|
||||
const parsed = Number(token);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) throw new Error("Scholar profile IDs must be positive integers.");
|
||||
deduped.add(parsed);
|
||||
}
|
||||
return [...deduped];
|
||||
}
|
||||
|
||||
function parseRepairUserIdOrThrow(raw: string): number {
|
||||
const parsed = Number((raw || "").trim());
|
||||
if (!Number.isInteger(parsed) || parsed < 1) throw new Error("Select a valid target user.");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function validateTypedConfirmation(): string {
|
||||
const normalized = repairConfirmationText.value.trim();
|
||||
if (typedConfirmationRequired.value && normalized !== APPLY_ALL_USERS_CONFIRM_TEXT) {
|
||||
throw new Error(`Type '${APPLY_ALL_USERS_CONFIRM_TEXT}' to apply repair for all users.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseBoundedNumber(raw: string, opts: { minimum: number; maximum: number; fallback: number }): number {
|
||||
const parsed = Number(raw.trim());
|
||||
if (!Number.isFinite(parsed)) return opts.fallback;
|
||||
return Math.max(opts.minimum, Math.min(opts.maximum, parsed));
|
||||
}
|
||||
|
||||
function nearDuplicatePayloadBase() {
|
||||
return {
|
||||
similarity_threshold: parseBoundedNumber(nearDuplicateSimilarityThreshold.value, { minimum: 0.5, maximum: 1.0, fallback: 0.78 }),
|
||||
min_shared_tokens: Math.trunc(parseBoundedNumber(nearDuplicateMinSharedTokens.value, { minimum: 1, maximum: 8, fallback: 3 })),
|
||||
max_year_delta: Math.trunc(parseBoundedNumber(nearDuplicateMaxYearDelta.value, { minimum: 0, maximum: 5, fallback: 1 })),
|
||||
max_clusters: Math.trunc(parseBoundedNumber(nearDuplicateMaxClusters.value, { minimum: 1, maximum: 200, fallback: 25 })),
|
||||
requested_by: nearDuplicateRequestedBy.value.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function checkboxEventChecked(event: Event): boolean {
|
||||
return event.target instanceof HTMLInputElement ? event.target.checked : false;
|
||||
}
|
||||
|
||||
function summaryCount(job: AdminDbRepairJob, key: string): string {
|
||||
const value = job.summary[key];
|
||||
return typeof value === "number" ? String(value) : "n/a";
|
||||
}
|
||||
|
||||
function ensureRepairUserSelected(): void {
|
||||
if (repairScopeMode.value !== SCOPE_SINGLE_USER || repairUserId.value || props.users.length === 0) return;
|
||||
repairUserId.value = String(props.users[0].id);
|
||||
}
|
||||
|
||||
async function refreshRepairJobs(): Promise<void> {
|
||||
refreshingJobs.value = true;
|
||||
try { repairJobs.value = await listAdminDbRepairJobs(30); } finally { refreshingJobs.value = false; }
|
||||
}
|
||||
|
||||
async function onRunRepair(): Promise<void> {
|
||||
runningRepair.value = true;
|
||||
clearAlerts();
|
||||
try {
|
||||
const payload = {
|
||||
scope_mode: repairScopeMode.value,
|
||||
scholar_profile_ids: parseScholarIds(repairScholarIds.value),
|
||||
dry_run: repairDryRun.value,
|
||||
gc_orphan_publications: repairGcOrphans.value,
|
||||
requested_by: repairRequestedBy.value.trim() || undefined,
|
||||
confirmation_text: validateTypedConfirmation() || undefined,
|
||||
user_id: repairScopeMode.value === SCOPE_SINGLE_USER ? parseRepairUserIdOrThrow(repairUserId.value) : undefined,
|
||||
};
|
||||
const result = await triggerPublicationLinkRepair(payload);
|
||||
lastRepairResult.value = result;
|
||||
setSuccess(`Repair job #${result.job_id} completed (${result.status}).`);
|
||||
repairConfirmationText.value = "";
|
||||
await refreshRepairJobs();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to run publication link repair.");
|
||||
} finally {
|
||||
runningRepair.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onToggleNearDuplicateClusterSelection(clusterKey: string, checked: boolean): void {
|
||||
const next = new Set(nearDuplicateSelectedClusterKeys.value);
|
||||
if (checked) { next.add(clusterKey); } else { next.delete(clusterKey); }
|
||||
nearDuplicateSelectedClusterKeys.value = next;
|
||||
}
|
||||
|
||||
async function onRunNearDuplicateScan(): Promise<void> {
|
||||
runningNearDuplicateScan.value = true;
|
||||
clearAlerts();
|
||||
try {
|
||||
const result = await triggerPublicationNearDuplicateRepair({ dry_run: true, ...nearDuplicatePayloadBase() });
|
||||
nearDuplicateClusters.value = result.clusters;
|
||||
nearDuplicateSelectedClusterKeys.value = new Set();
|
||||
nearDuplicateConfirmationText.value = "";
|
||||
lastNearDuplicateResult.value = result;
|
||||
setSuccess(`Near-duplicate scan completed (job #${result.job_id}).`);
|
||||
await refreshRepairJobs();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to scan for near-duplicate publications.");
|
||||
} finally {
|
||||
runningNearDuplicateScan.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onApplyNearDuplicateRepair(): Promise<void> {
|
||||
applyingNearDuplicateRepair.value = true;
|
||||
clearAlerts();
|
||||
try {
|
||||
const selectedKeys = [...nearDuplicateSelectedClusterKeys.value].sort((a, b) => a.localeCompare(b));
|
||||
if (selectedKeys.length === 0) throw new Error("Select at least one near-duplicate cluster before applying.");
|
||||
if (nearDuplicateConfirmationText.value.trim() !== APPLY_NEAR_DUPLICATES_CONFIRM_TEXT) {
|
||||
throw new Error(`Type '${APPLY_NEAR_DUPLICATES_CONFIRM_TEXT}' to confirm merge.`);
|
||||
}
|
||||
const result = await triggerPublicationNearDuplicateRepair({
|
||||
dry_run: false,
|
||||
selected_cluster_keys: selectedKeys,
|
||||
confirmation_text: nearDuplicateConfirmationText.value.trim(),
|
||||
...nearDuplicatePayloadBase(),
|
||||
});
|
||||
nearDuplicateClusters.value = result.clusters;
|
||||
nearDuplicateSelectedClusterKeys.value = new Set();
|
||||
nearDuplicateConfirmationText.value = "";
|
||||
lastNearDuplicateResult.value = result;
|
||||
setSuccess(`Merged selected near-duplicate clusters (job #${result.job_id}).`);
|
||||
await refreshRepairJobs();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to apply near-duplicate merge.");
|
||||
} finally {
|
||||
applyingNearDuplicateRepair.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onDropAllPublications(): Promise<void> {
|
||||
droppingPublications.value = true;
|
||||
clearAlerts();
|
||||
dropResult.value = null;
|
||||
try {
|
||||
const result = await dropAllPublications(dropConfirmationText.value.trim());
|
||||
dropResult.value = result;
|
||||
setSuccess(result.message);
|
||||
dropConfirmationText.value = "";
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to drop publications.");
|
||||
} finally {
|
||||
droppingPublications.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
await refreshRepairJobs();
|
||||
ensureRepairUserSelected();
|
||||
}
|
||||
|
||||
defineExpose({ load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="grid gap-4">
|
||||
<AppCard class="space-y-3">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Publication Link Repair</h2>
|
||||
<AppHelpHint text="Dry-run first. For all-users apply mode, typed confirmation is required." />
|
||||
</div>
|
||||
<form class="grid gap-3 md:grid-cols-2" @submit.prevent="onRunRepair">
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>Scope</span>
|
||||
<AppSelect v-model="repairScopeMode" @change="ensureRepairUserSelected">
|
||||
<option :value="SCOPE_SINGLE_USER">Single user</option>
|
||||
<option :value="SCOPE_ALL_USERS">All users</option>
|
||||
</AppSelect>
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>Target user</span>
|
||||
<AppSelect v-model="repairUserId" :disabled="repairScopeMode === SCOPE_ALL_USERS || users.length === 0">
|
||||
<option value="" disabled>Select user</option>
|
||||
<option v-for="user in users" :key="user.id" :value="String(user.id)">{{ user.email }} (ID {{ user.id }})</option>
|
||||
</AppSelect>
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2">
|
||||
<span>Scholar profile IDs (optional)</span>
|
||||
<AppInput v-model="repairScholarIds" placeholder="e.g. 12,13,14" />
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2">
|
||||
<span>Requested by (optional)</span>
|
||||
<AppInput v-model="repairRequestedBy" placeholder="email/name/ticket id" />
|
||||
</label>
|
||||
<div class="flex flex-wrap items-center gap-4 md:col-span-2">
|
||||
<AppCheckbox id="repair-dry-run" v-model="repairDryRun" label="Dry-run (no writes)" />
|
||||
<AppCheckbox id="repair-gc-orphans" v-model="repairGcOrphans" label="Delete orphan publications" />
|
||||
</div>
|
||||
<label v-if="typedConfirmationRequired" class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2">
|
||||
<span>Type '{{ APPLY_ALL_USERS_CONFIRM_TEXT }}' to confirm</span>
|
||||
<AppInput v-model="repairConfirmationText" :placeholder="APPLY_ALL_USERS_CONFIRM_TEXT" />
|
||||
</label>
|
||||
<p v-if="repairScopeMode === SCOPE_ALL_USERS" class="text-xs text-secondary md:col-span-2">All-users scope includes every scholar profile across all accounts.</p>
|
||||
<div class="md:col-span-2">
|
||||
<AppButton type="submit" :disabled="runningRepair">{{ runningRepair ? "Running..." : "Run repair job" }}</AppButton>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="lastRepairResult" class="rounded-lg border border-stroke-default bg-surface-card-muted p-3 text-xs">
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||
<AppBadge :tone="statusTone(lastRepairResult.status)">Job #{{ lastRepairResult.job_id }}</AppBadge>
|
||||
<span class="text-secondary">Status: {{ lastRepairResult.status }}</span>
|
||||
</div>
|
||||
<pre class="overflow-x-auto text-secondary">{{ JSON.stringify(lastRepairResult.summary, null, 2) }}</pre>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard class="space-y-3">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Near-Duplicate Publication Repair</h2>
|
||||
<AppHelpHint text="Run a dry-run scan first, verify candidate clusters, then merge only selected clusters." />
|
||||
</div>
|
||||
<form class="grid gap-3 md:grid-cols-2" @submit.prevent="onRunNearDuplicateScan">
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Similarity threshold</span><AppInput v-model="nearDuplicateSimilarityThreshold" placeholder="0.78" /></label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Min shared tokens</span><AppInput v-model="nearDuplicateMinSharedTokens" placeholder="3" /></label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Max year delta</span><AppInput v-model="nearDuplicateMaxYearDelta" placeholder="1" /></label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Max preview clusters</span><AppInput v-model="nearDuplicateMaxClusters" placeholder="25" /></label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2"><span>Requested by (optional)</span><AppInput v-model="nearDuplicateRequestedBy" placeholder="email/name/ticket id" /></label>
|
||||
<div class="md:col-span-2">
|
||||
<AppButton type="submit" :disabled="runningNearDuplicateScan">{{ runningNearDuplicateScan ? "Scanning..." : "Scan near-duplicate clusters" }}</AppButton>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="nearDuplicateClusters.length > 0" class="space-y-3">
|
||||
<AppTable label="Near duplicate clusters table">
|
||||
<thead><tr><th scope="col">Select</th><th scope="col">Cluster</th><th scope="col">Winner</th><th scope="col">Members</th><th scope="col">Similarity</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="cluster in nearDuplicateClusters" :key="cluster.cluster_key">
|
||||
<td><input :id="`near-dup-${cluster.cluster_key}`" class="h-4 w-4 rounded border-stroke-default bg-surface-card" type="checkbox" :checked="nearDuplicateSelectedClusterKeys.has(cluster.cluster_key)" @change="onToggleNearDuplicateClusterSelection(cluster.cluster_key, checkboxEventChecked($event))" /></td>
|
||||
<td class="font-mono text-xs">{{ cluster.cluster_key }}</td>
|
||||
<td>#{{ cluster.winner_publication_id }}</td>
|
||||
<td><div class="grid gap-1"><span v-for="member in cluster.members" :key="member.publication_id" class="text-xs text-secondary">#{{ member.publication_id }} · {{ member.title }}</span></div></td>
|
||||
<td>{{ cluster.similarity_score.toFixed(2) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</AppTable>
|
||||
<form class="grid gap-3 md:grid-cols-2" @submit.prevent="onApplyNearDuplicateRepair">
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2"><span>Type '{{ APPLY_NEAR_DUPLICATES_CONFIRM_TEXT }}' to merge selected clusters</span><AppInput v-model="nearDuplicateConfirmationText" :placeholder="APPLY_NEAR_DUPLICATES_CONFIRM_TEXT" /></label>
|
||||
<div class="md:col-span-2"><AppButton type="submit" :disabled="applyingNearDuplicateRepair || !nearDuplicateApplyEnabled">{{ applyingNearDuplicateRepair ? "Merging..." : "Merge selected clusters" }}</AppButton></div>
|
||||
</form>
|
||||
</div>
|
||||
<div v-if="lastNearDuplicateResult" class="rounded-lg border border-stroke-default bg-surface-card-muted p-3 text-xs">
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2"><AppBadge :tone="statusTone(lastNearDuplicateResult.status)">Job #{{ lastNearDuplicateResult.job_id }}</AppBadge><span class="text-secondary">Status: {{ lastNearDuplicateResult.status }}</span></div>
|
||||
<pre class="overflow-x-auto text-secondary">{{ JSON.stringify(lastNearDuplicateResult.summary, null, 2) }}</pre>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1"><h2 class="text-lg font-semibold text-ink-primary">Recent Repair Jobs</h2><AppHelpHint text="Audit history and summary counters for each repair job." /></div>
|
||||
<AppRefreshButton variant="secondary" :loading="refreshingJobs" title="Refresh repair jobs" loading-title="Refreshing repair jobs" @click="refreshRepairJobs" />
|
||||
</div>
|
||||
<AppTable label="Repair jobs table">
|
||||
<thead><tr><th scope="col">Job</th><th scope="col">Status</th><th scope="col">Mode</th><th scope="col">Requested by</th><th scope="col">Created</th><th scope="col">Links in scope</th><th scope="col">Links deleted</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="job in repairJobs" :key="job.id">
|
||||
<td>#{{ job.id }} · {{ job.job_name }}</td>
|
||||
<td><AppBadge :tone="statusTone(job.status)">{{ job.status }}</AppBadge></td>
|
||||
<td>{{ job.dry_run ? "dry-run" : "apply" }}</td>
|
||||
<td>{{ job.requested_by || "n/a" }}</td>
|
||||
<td>{{ formatTimestamp(job.created_at) }}</td>
|
||||
<td>{{ summaryCount(job, "links_in_scope") }}</td>
|
||||
<td>{{ summaryCount(job, "links_deleted") }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</AppTable>
|
||||
</AppCard>
|
||||
|
||||
<AppCard class="space-y-3 border-state-danger-border">
|
||||
<div class="flex items-center gap-1"><h2 class="text-lg font-semibold text-state-danger-text">Drop All Publications</h2><AppHelpHint text="Permanently delete ALL publications, links, identifiers, and PDF jobs. Scholar baselines will be reset so the next run re-discovers everything." /></div>
|
||||
<p class="text-sm text-secondary">This action is <strong>irreversible</strong>. It deletes every publication record across all users. The next ingestion run will re-populate all data from scratch.</p>
|
||||
<form class="grid gap-3" @submit.prevent="onDropAllPublications">
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Type '{{ DROP_PUBLICATIONS_CONFIRM_TEXT }}' to confirm</span><AppInput v-model="dropConfirmationText" :placeholder="DROP_PUBLICATIONS_CONFIRM_TEXT" autocomplete="off" /></label>
|
||||
<div><AppButton type="submit" variant="danger" :disabled="droppingPublications || !dropConfirmationValid">{{ droppingPublications ? "Dropping..." : "Drop all publications" }}</AppButton></div>
|
||||
</form>
|
||||
<div v-if="dropResult" class="rounded-lg border border-stroke-default bg-surface-card-muted p-3 text-xs">
|
||||
<div class="mb-1 flex items-center gap-2"><AppBadge tone="danger">Deleted: {{ dropResult.deleted_count }}</AppBadge></div>
|
||||
<p class="text-secondary">{{ dropResult.message }}</p>
|
||||
</div>
|
||||
</AppCard>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import AdminUsersSection from "./AdminUsersSection.vue";
|
||||
|
||||
vi.mock("@/features/admin_users", () => ({
|
||||
listAdminUsers: vi.fn(),
|
||||
createAdminUser: vi.fn(),
|
||||
setAdminUserActive: vi.fn(),
|
||||
resetAdminUserPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listAdminUsers, createAdminUser } from "@/features/admin_users";
|
||||
|
||||
const mockedListUsers = vi.mocked(listAdminUsers);
|
||||
const mockedCreateUser = vi.mocked(createAdminUser);
|
||||
|
||||
function buildUser(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
email: "user@example.com",
|
||||
is_admin: false,
|
||||
is_active: true,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AdminUsersSection", () => {
|
||||
beforeEach(() => {
|
||||
mockedListUsers.mockReset();
|
||||
mockedCreateUser.mockReset();
|
||||
});
|
||||
|
||||
it("renders the create user form", () => {
|
||||
const wrapper = mount(AdminUsersSection);
|
||||
expect(wrapper.text()).toContain("Create user");
|
||||
});
|
||||
|
||||
it("exposes load function that calls listAdminUsers", async () => {
|
||||
mockedListUsers.mockResolvedValueOnce([buildUser()]);
|
||||
const wrapper = mount(AdminUsersSection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void>; users: unknown[] };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(mockedListUsers).toHaveBeenCalled();
|
||||
expect(vm.users).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders user table after loading", async () => {
|
||||
mockedListUsers.mockResolvedValueOnce([buildUser({ email: "alice@test.com" })]);
|
||||
const wrapper = mount(AdminUsersSection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await vm.load();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("alice@test.com");
|
||||
});
|
||||
|
||||
it("propagates error when load fails", async () => {
|
||||
mockedListUsers.mockRejectedValueOnce(new Error("Network error"));
|
||||
const wrapper = mount(AdminUsersSection);
|
||||
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
|
||||
await expect(vm.load()).rejects.toThrow("Network error");
|
||||
});
|
||||
|
||||
it("emits user creation via form submit", async () => {
|
||||
mockedCreateUser.mockResolvedValueOnce(buildUser({ email: "new@test.com" }));
|
||||
mockedListUsers.mockResolvedValue([]);
|
||||
const wrapper = mount(AdminUsersSection);
|
||||
|
||||
const inputs = wrapper.findAll("input");
|
||||
const emailInput = inputs.find((i) => (i.element as HTMLInputElement).type === "email");
|
||||
const passwordInput = inputs.find((i) => (i.element as HTMLInputElement).type === "password");
|
||||
|
||||
if (emailInput && passwordInput) {
|
||||
await emailInput.setValue("new@test.com");
|
||||
await passwordInput.setValue("securepassword123");
|
||||
await wrapper.find("form").trigger("submit");
|
||||
await flushPromises();
|
||||
expect(mockedCreateUser).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
211
frontend/src/features/settings/components/AdminUsersSection.vue
Normal file
211
frontend/src/features/settings/components/AdminUsersSection.vue
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppCard from "@/components/ui/AppCard.vue";
|
||||
import AppCheckbox from "@/components/ui/AppCheckbox.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import AppModal from "@/components/ui/AppModal.vue";
|
||||
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
|
||||
import AppTable from "@/components/ui/AppTable.vue";
|
||||
import {
|
||||
createAdminUser,
|
||||
listAdminUsers,
|
||||
resetAdminUserPassword,
|
||||
setAdminUserActive,
|
||||
type AdminUser,
|
||||
} from "@/features/admin_users";
|
||||
import { useRequestState } from "@/composables/useRequestState";
|
||||
|
||||
const { errorMessage, errorRequestId, successMessage, clearAlerts, assignError, setSuccess } = useRequestState();
|
||||
|
||||
const refreshingUsers = ref(false);
|
||||
const creating = ref(false);
|
||||
const togglingUserId = ref<number | null>(null);
|
||||
const resettingPassword = ref(false);
|
||||
|
||||
const users = ref<AdminUser[]>([]);
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const createIsAdmin = ref(false);
|
||||
const activeUserId = ref<number | null>(null);
|
||||
const resetPassword = ref("");
|
||||
|
||||
const activeUser = computed(() => users.value.find((u) => u.id === activeUserId.value) ?? null);
|
||||
|
||||
function userRoleLabel(user: AdminUser): string {
|
||||
return user.is_admin ? "Admin" : "User";
|
||||
}
|
||||
|
||||
function statusDotClass(user: AdminUser): string {
|
||||
return user.is_active ? "bg-success-500 ring-success-200" : "bg-ink-muted/70 ring-stroke-default";
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null): string {
|
||||
if (!value) return "n/a";
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function openUserModal(user: AdminUser): void {
|
||||
activeUserId.value = user.id;
|
||||
resetPassword.value = "";
|
||||
}
|
||||
|
||||
function closeUserModal(): void {
|
||||
activeUserId.value = null;
|
||||
resetPassword.value = "";
|
||||
}
|
||||
|
||||
async function refreshUsers(): Promise<void> {
|
||||
refreshingUsers.value = true;
|
||||
try {
|
||||
users.value = await listAdminUsers();
|
||||
if (activeUserId.value !== null && !users.value.some((item) => item.id === activeUserId.value)) {
|
||||
closeUserModal();
|
||||
}
|
||||
} finally {
|
||||
refreshingUsers.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onCreateUser(): Promise<void> {
|
||||
creating.value = true;
|
||||
clearAlerts();
|
||||
try {
|
||||
if (!email.value.trim() || !password.value) throw new Error("Email and password are required.");
|
||||
const created = await createAdminUser({ email: email.value.trim(), password: password.value, is_admin: createIsAdmin.value });
|
||||
email.value = "";
|
||||
password.value = "";
|
||||
createIsAdmin.value = false;
|
||||
setSuccess(`User created: ${created.email}`);
|
||||
await refreshUsers();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to create user.");
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleUser(user: AdminUser): Promise<void> {
|
||||
togglingUserId.value = user.id;
|
||||
clearAlerts();
|
||||
try {
|
||||
const updated = await setAdminUserActive(user.id, !user.is_active);
|
||||
setSuccess(`${updated.email} is now ${updated.is_active ? "active" : "inactive"}.`);
|
||||
await refreshUsers();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to update user.");
|
||||
} finally {
|
||||
togglingUserId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onResetPassword(): Promise<void> {
|
||||
const user = activeUser.value;
|
||||
if (!user) return;
|
||||
resettingPassword.value = true;
|
||||
clearAlerts();
|
||||
try {
|
||||
const candidate = resetPassword.value.trim();
|
||||
if (candidate.length < 12) throw new Error("New password must be at least 12 characters.");
|
||||
const result = await resetAdminUserPassword(user.id, candidate);
|
||||
resetPassword.value = "";
|
||||
setSuccess(result.message || `Password reset for ${user.email}.`);
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to reset password.");
|
||||
} finally {
|
||||
resettingPassword.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
await refreshUsers();
|
||||
}
|
||||
|
||||
defineExpose({ load, errorMessage, errorRequestId, successMessage, users });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppCard class="space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Users</h2>
|
||||
<AppHelpHint text="Create accounts, toggle active status, and reset passwords." />
|
||||
</div>
|
||||
<AppRefreshButton variant="secondary" :loading="refreshingUsers" title="Refresh users" loading-title="Refreshing users" @click="refreshUsers" />
|
||||
</div>
|
||||
|
||||
<form class="grid gap-3 md:grid-cols-3" @submit.prevent="onCreateUser">
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>Email</span>
|
||||
<AppInput v-model="email" type="email" autocomplete="off" />
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>Password</span>
|
||||
<AppInput v-model="password" type="password" autocomplete="new-password" />
|
||||
</label>
|
||||
<div class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||
<span>Role</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<AppCheckbox id="admin-create-user-is-admin" v-model="createIsAdmin" label="Grant admin" />
|
||||
<AppButton type="submit" :disabled="creating">{{ creating ? "Creating..." : "Create user" }}</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<AppTable label="Users table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Email</th>
|
||||
<th scope="col">Role</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td class="align-middle">
|
||||
<button type="button" class="group inline-flex items-center gap-2 rounded-md px-1 py-0.5 text-left text-ink-primary transition hover:bg-surface-card-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset" @click="openUserModal(user)">
|
||||
<span :class="statusDotClass(user)" class="h-2.5 w-2.5 rounded-full ring-2" :aria-label="user.is_active ? 'Active user' : 'Inactive user'" />
|
||||
<span class="underline-offset-2 group-hover:underline">{{ user.email }}</span>
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ userRoleLabel(user) }}</td>
|
||||
<td>{{ user.is_active ? "active" : "inactive" }}</td>
|
||||
<td class="flex flex-wrap items-center gap-2">
|
||||
<AppButton variant="secondary" :disabled="togglingUserId === user.id" @click="onToggleUser(user)">
|
||||
{{ togglingUserId === user.id ? "Updating..." : user.is_active ? "Deactivate" : "Activate" }}
|
||||
</AppButton>
|
||||
<AppButton variant="ghost" @click="openUserModal(user)">Manage</AppButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</AppTable>
|
||||
</AppCard>
|
||||
|
||||
<AppModal :open="activeUser !== null" title="User settings" @close="closeUserModal">
|
||||
<div v-if="activeUser" class="grid gap-4">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="statusDotClass(activeUser)" class="h-2.5 w-2.5 rounded-full ring-2" :aria-label="activeUser.is_active ? 'Active user' : 'Inactive user'" />
|
||||
<p class="truncate text-sm font-semibold text-ink-primary">{{ activeUser.email }}</p>
|
||||
</div>
|
||||
<p class="text-sm text-secondary">Role: {{ userRoleLabel(activeUser) }}</p>
|
||||
<p class="text-xs text-secondary">Last updated: {{ formatTimestamp(activeUser.updated_at) }}</p>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>New password</span>
|
||||
<AppInput id="admin-reset-password" v-model="resetPassword" type="password" autocomplete="new-password" placeholder="At least 12 characters" />
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<AppButton :disabled="resettingPassword" @click="onResetPassword">{{ resettingPassword ? "Resetting..." : "Reset password" }}</AppButton>
|
||||
<AppButton variant="secondary" :disabled="togglingUserId === activeUser.id" @click="onToggleUser(activeUser)">
|
||||
{{ togglingUserId === activeUser.id ? "Updating..." : activeUser.is_active ? "Deactivate user" : "Activate user" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppModal>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue