fix(frontend): add cooldown tooltips and fix settings tab initial load
Add AppHelpHint tooltips to ScrapeSafetyBadge and ScrapeSafetyBanner showing rate-limit explanation, cooldown reason, and recommended action. When no cooldown is active, show a simple "ready" message. Fix settings tabs not loading on initial navigation by deferring onMounted load via nextTick, and using flush:'post' on the section watcher so template refs are attached before load() runs.
This commit is contained in:
parent
67dd297b87
commit
151e0774d8
5 changed files with 194 additions and 6 deletions
61
frontend/src/components/patterns/ScrapeSafetyBadge.test.ts
Normal file
61
frontend/src/components/patterns/ScrapeSafetyBadge.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ScrapeSafetyBadge from "./ScrapeSafetyBadge.vue";
|
||||
import { createDefaultSafetyState, type ScrapeSafetyState } from "@/features/safety";
|
||||
|
||||
function buildState(overrides: Partial<ScrapeSafetyState> = {}): ScrapeSafetyState {
|
||||
return { ...createDefaultSafetyState(), ...overrides };
|
||||
}
|
||||
|
||||
describe("ScrapeSafetyBadge", () => {
|
||||
it("shows ready tooltip when cooldown is inactive", () => {
|
||||
const wrapper = mount(ScrapeSafetyBadge, {
|
||||
props: { state: buildState({ cooldown_active: false }) },
|
||||
});
|
||||
expect(wrapper.text()).toContain("Safety ready");
|
||||
const hint = wrapper.findComponent({ name: "AppHelpHint" });
|
||||
expect(hint.exists()).toBe(true);
|
||||
expect(hint.props("text")).toContain("No active cooldown");
|
||||
});
|
||||
|
||||
it("shows cooldown tooltip with reason and action when active", () => {
|
||||
const wrapper = mount(ScrapeSafetyBadge, {
|
||||
props: {
|
||||
state: buildState({
|
||||
cooldown_active: true,
|
||||
cooldown_reason: "blocked_failure_threshold_exceeded",
|
||||
cooldown_reason_label: "Too many blocked requests",
|
||||
recommended_action: "Wait for cooldown to expire",
|
||||
cooldown_remaining_seconds: 120,
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(wrapper.text()).toContain("Safety cooldown");
|
||||
const hint = wrapper.findComponent({ name: "AppHelpHint" });
|
||||
expect(hint.exists()).toBe(true);
|
||||
const text = hint.props("text") as string;
|
||||
expect(text).toContain("Google Scholar rate-limits");
|
||||
expect(text).toContain("Too many blocked requests");
|
||||
expect(text).toContain("Wait for cooldown to expire");
|
||||
});
|
||||
|
||||
it("shows cooldown tooltip without optional fields", () => {
|
||||
const wrapper = mount(ScrapeSafetyBadge, {
|
||||
props: {
|
||||
state: buildState({
|
||||
cooldown_active: true,
|
||||
cooldown_reason: "some_reason",
|
||||
cooldown_reason_label: null,
|
||||
recommended_action: null,
|
||||
cooldown_remaining_seconds: 60,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const hint = wrapper.findComponent({ name: "AppHelpHint" });
|
||||
const text = hint.props("text") as string;
|
||||
expect(text).toContain("Google Scholar rate-limits");
|
||||
expect(text).not.toContain("Why:");
|
||||
expect(text).not.toContain("Action:");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import { type ScrapeSafetyState } from "@/features/safety";
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -18,10 +19,30 @@ const toneClass = computed(() => {
|
|||
}
|
||||
return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
|
||||
});
|
||||
|
||||
const READY_TOOLTIP = "No active cooldown. Scraping can proceed normally.";
|
||||
const RATE_LIMIT_INTRO =
|
||||
"Google Scholar rate-limits automated requests. The cooldown pauses scraping to avoid your IP being blocked.";
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (!props.state.cooldown_active) return READY_TOOLTIP;
|
||||
|
||||
const parts = [RATE_LIMIT_INTRO];
|
||||
if (props.state.cooldown_reason_label) {
|
||||
parts.push(`Why: ${props.state.cooldown_reason_label}`);
|
||||
}
|
||||
if (props.state.recommended_action) {
|
||||
parts.push(`Action: ${props.state.recommended_action}`);
|
||||
}
|
||||
return parts.join(" \u2014 ");
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold" :class="toneClass">
|
||||
{{ label }}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold" :class="toneClass">
|
||||
{{ label }}
|
||||
</span>
|
||||
<AppHelpHint :text="tooltipText" />
|
||||
</span>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
import AppAlert from "@/components/ui/AppAlert.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import {
|
||||
formatCooldownCountdown,
|
||||
type ScrapeSafetyState,
|
||||
|
|
@ -79,6 +80,10 @@ const actionText = computed(() => {
|
|||
return props.safetyState.recommended_action;
|
||||
});
|
||||
|
||||
const bannerHintText = computed(() => {
|
||||
return "Google Scholar rate-limits automated requests. The cooldown pauses scraping to avoid your IP being blocked.";
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
timer = setInterval(() => {
|
||||
now.value = Date.now();
|
||||
|
|
@ -95,7 +100,12 @@ onBeforeUnmount(() => {
|
|||
|
||||
<template>
|
||||
<AppAlert v-if="isVisible" :tone="tone">
|
||||
<template #title>{{ title }}</template>
|
||||
<template #title>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{{ title }}
|
||||
<AppHelpHint :text="bannerHintText" />
|
||||
</span>
|
||||
</template>
|
||||
<p>{{ detailText }}</p>
|
||||
<p v-if="actionText" class="text-secondary">{{ actionText }}</p>
|
||||
</AppAlert>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue