diff --git a/NEWS.md b/NEWS.md index 159343399..77f102bb5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,12 @@ width: 128px; border-radius: 128px; " /> +## v2.5.4 + +- Features + - Online tagging matches to review show a toolbar Review button and a + tab-title count instead of popping up a dialog. + ## v2.5.3 - Features diff --git a/frontend/src/app.vue b/frontend/src/app.vue index 6b634372e..e98167ef5 100644 --- a/frontend/src/app.vue +++ b/frontend/src/app.vue @@ -13,6 +13,7 @@ import DockerHubDeprecatedSnackbar from "@/components/docker-hub-deprecated-snac import SessionErrorSnackbar from "@/components/session-error-snackbar.vue"; import { useAuthStore } from "@/stores/auth"; import { useFavoritesStore } from "@/stores/favorites"; +import { pendingTitle, useOnlineTagStore } from "@/stores/online-tag"; import { useSocketStore } from "@/stores/socket"; export default { @@ -21,7 +22,20 @@ export default { DockerHubDeprecatedSnackbar, SessionErrorSnackbar, }, + head() { + /* + * "(N) " on whatever title the page sets, so a background tab shows + * matches waiting for review. Read the count here, not inside the + * template function: unhead calls that at render time, outside this + * effect, so only what head() itself reads re-runs it. + */ + const count = this.isUserAdmin ? this.pendingComicCount : 0; + if (!count) return {}; + return { titleTemplate: (title) => pendingTitle(count, title) }; + }, computed: { + ...mapState(useOnlineTagStore, ["pendingComicCount"]), + ...mapState(useAuthStore, ["isUserAdmin"]), ...mapState(useAuthStore, { user: (state) => state.user, /* diff --git a/frontend/src/components/admin/admin-header.vue b/frontend/src/components/admin/admin-header.vue index e382fbf72..b177a68a4 100644 --- a/frontend/src/components/admin/admin-header.vue +++ b/frontend/src/components/admin/admin-header.vue @@ -4,6 +4,7 @@ Codex Administration + 0; @@ -97,14 +97,7 @@ export default { return this.pendingPrompts.length > 0; }, promptsLabel() { - // Comics, not questions: one question can hold a whole series, and the - // Tagging tab counts the rows it will mark for review. A prompt that - // names no comic at all still counts as one thing to look at, so a - // malformed cache entry can't make a visible queue read as empty. - const count = this.pendingPrompts.reduce( - (total, prompt) => total + Math.max(promptComics(prompt).length, 1), - 0, - ); + const count = this.pendingComicCount; return `${count} Match${count === 1 ? "" : "es"} to Review`; }, showAdminPanelLink() { @@ -127,9 +120,9 @@ export default { this.librarianTask("poll"); }, openPrompts() { - // The OnlineTagPromptPopup (mounted in browser.vue + admin.vue) watches - // this flag; flipping it opens the same Match Review dialog the browser - // toolbar button does. + // The OnlineTagPromptPopup (mounted in browser.vue, admin.vue and + // reader.vue) watches this flag; flipping it opens the same Match + // Review dialog the toolbar Review button does. this.promptDialogOpen = true; }, }, diff --git a/frontend/src/components/admin/drawer/admin-settings-button-prompts.vue b/frontend/src/components/admin/drawer/admin-settings-button-prompts.vue deleted file mode 100644 index 69e81096d..000000000 --- a/frontend/src/components/admin/drawer/admin-settings-button-prompts.vue +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - diff --git a/frontend/src/components/browser/toolbars/top/browser-toolbar-top.vue b/frontend/src/components/browser/toolbars/top/browser-toolbar-top.vue index bffa71f1a..2ecfecfac 100644 --- a/frontend/src/components/browser/toolbars/top/browser-toolbar-top.vue +++ b/frontend/src/components/browser/toolbars/top/browser-toolbar-top.vue @@ -8,6 +8,7 @@ + @@ -27,6 +28,7 @@ import BrowserOrderReverseButton from "@/components/browser/toolbars/top/order-r import BrowserSearchButton from "@/components/browser/toolbars/top/search-button.vue"; import BrowserTopCollectionSelect from "@/components/browser/toolbars/top/top-collection-select.vue"; import BrowserViewModeToggle from "@/components/browser/toolbars/top/view-mode-toggle.vue"; +import OnlineTagReviewButton from "@/components/online-tag/review-button.vue"; import SettingsDrawerButton from "@/components/settings/button.vue"; import { useBrowserStore } from "@/stores/browser"; @@ -40,6 +42,7 @@ export default { BrowserOrderBySelect, BrowserOrderReverseButton, BrowserViewModeToggle, + OnlineTagReviewButton, SettingsDrawerButton, }, data() { diff --git a/frontend/src/components/online-tag/review-button.vue b/frontend/src/components/online-tag/review-button.vue new file mode 100644 index 000000000..52819fe37 --- /dev/null +++ b/frontend/src/components/online-tag/review-button.vue @@ -0,0 +1,74 @@ + + + + + + diff --git a/frontend/src/components/reader/toolbars/top/reader-toolbar-top.vue b/frontend/src/components/reader/toolbars/top/reader-toolbar-top.vue index 908bd0916..13c157809 100644 --- a/frontend/src/components/reader/toolbars/top/reader-toolbar-top.vue +++ b/frontend/src/components/reader/toolbars/top/reader-toolbar-top.vue @@ -20,6 +20,7 @@ /> + - @@ -37,18 +36,11 @@ const AdminSettingsButtonErrors = markRaw( ), ); -const AdminSettingsButtonPrompts = markRaw( - defineAsyncComponent( - () => import("@/components/admin/drawer/admin-settings-button-prompts.vue"), - ), -); - export default { name: "SettingsDrawerButton", components: { AdminSettingsButtonErrors, AdminSettingsButtonProgress, - AdminSettingsButtonPrompts, ScaleButton, }, data() { @@ -56,7 +48,6 @@ export default { mdiMenu, AdminSettingsButtonProgress, AdminSettingsButtonErrors, - AdminSettingsButtonPrompts, }; }, computed: { diff --git a/frontend/src/stores/online-tag.js b/frontend/src/stores/online-tag.js index f75e9e3fd..f8a6a8307 100644 --- a/frontend/src/stores/online-tag.js +++ b/frontend/src/stores/online-tag.js @@ -2,6 +2,14 @@ import { defineStore } from "pinia"; import { HTTP } from "@/api/v4/base"; +/* + * The document title with a "(N) " prefix while N matches wait for review, so + * a background tab announces them. No title, nothing to prefix. + */ +export function pendingTitle(count, title) { + return title && count > 0 ? `(${count}) ${title}` : title; +} + /* * Every comic one match-review prompt answers for, representative first. * @@ -46,6 +54,18 @@ export const useOnlineTagStore = defineStore("onlineTag", { // rendered. _promptsRequestId: 0, }), + getters: { + pendingComicCount(state) { + // Comics, not questions: one question can hold a whole series, and the + // Tagging tab counts the rows it will mark for review. A prompt that + // names no comic at all still counts as one thing to look at, so a + // malformed cache entry can't make a visible queue read as empty. + return state.pendingPrompts.reduce( + (total, prompt) => total + Math.max(promptComics(prompt).length, 1), + 0, + ); + }, + }, actions: { async startSession({ collection, @@ -81,7 +101,7 @@ export const useOnlineTagStore = defineStore("onlineTag", { this.activeSessionId = sid || null; return sid; }, - async loadPrompts({ autoOpen = true } = {}) { + async loadPrompts() { /* * Pending prompts persist in the cache independently of any running * scan, so they're fetched globally — no active session required. They @@ -105,17 +125,19 @@ export const useOnlineTagStore = defineStore("onlineTag", { this.pendingPrompts = raw.filter( (p) => !this.recentlyResolved.includes(p.fingerprint), ); + /* + * Only ever closes the dialog; it opens from a click on a Review + * button. Opening it on every deferral interrupted browsing in every + * admin tab for the length of a large run. + * + * Nothing left to review: close rather than leave the dialog sitting + * on an empty list. The queue empties behind this tab's back all the + * time — another tab answered, or the daemon resolved the last one — + * and the dialog used to stay open showing a spinner that no + * notification would ever end. + */ if (this.pendingPrompts.length === 0) { - /* - * Nothing left to review: close rather than leave the dialog - * sitting on an empty list. The queue empties behind this tab's - * back all the time — another tab answered, or the daemon resolved - * the last one — and the dialog used to stay open showing a - * spinner that no notification would ever end. - */ this.promptDialogOpen = false; - } else if (autoOpen && !this.promptDialogOpen) { - this.promptDialogOpen = true; } }, rememberResolved(fingerprint) { @@ -253,7 +275,7 @@ export const useOnlineTagStore = defineStore("onlineTag", { // is live, plus any prompts still awaiting an answer and the status // snapshot for the Tagging-tab table. await this.discoverSession(); - await this.loadPrompts({ autoOpen: false }); + await this.loadPrompts(); await this.loadSnapshot(); }, onPromptNotification() { diff --git a/frontend/tests/unit/online-tag-store.test.js b/frontend/tests/unit/online-tag-store.test.js index 415c4b33b..fb08c23d5 100644 --- a/frontend/tests/unit/online-tag-store.test.js +++ b/frontend/tests/unit/online-tag-store.test.js @@ -16,7 +16,7 @@ vi.mock("@/api/v4/base", () => ({ })); import { HTTP } from "@/api/v4/base"; -import { useOnlineTagStore } from "@/stores/online-tag"; +import { pendingTitle, useOnlineTagStore } from "@/stores/online-tag"; const fps = (store) => store.pendingPrompts.map((p) => p.fingerprint); const promptsResponse = (...fingerprints) => ({ @@ -40,13 +40,13 @@ describe("useOnlineTagStore — resolution reconciliation", () => { // Backend lagging: still returns the resolved prompt. HTTP.get.mockResolvedValue(promptsResponse("a", "b")); - await store.loadPrompts({ autoOpen: false }); + await store.loadPrompts(); expect(fps(store)).toEqual(["b"]); // "a" stays suppressed expect(store.recentlyResolved).toEqual(["a"]); // still echoed → kept // Backend catches up and drops it. HTTP.get.mockResolvedValue(promptsResponse("b")); - await store.loadPrompts({ autoOpen: false }); + await store.loadPrompts(); expect(fps(store)).toEqual(["b"]); expect(store.recentlyResolved).toEqual([]); // gone → forgotten }); @@ -59,7 +59,7 @@ describe("useOnlineTagStore — resolution reconciliation", () => { // The match drifted; the daemon re-queued a fresh prompt as "a2". HTTP.get.mockResolvedValue(promptsResponse("a2")); - await store.loadPrompts({ autoOpen: false }); + await store.loadPrompts(); expect(fps(store)).toEqual(["a2"]); }); @@ -76,7 +76,7 @@ describe("useOnlineTagStore — resolution reconciliation", () => { // A lagging echo of both is fully suppressed. HTTP.get.mockResolvedValue(promptsResponse("x", "y")); - await store.loadPrompts({ autoOpen: false }); + await store.loadPrompts(); expect(fps(store)).toEqual([]); }); @@ -189,6 +189,19 @@ describe("useOnlineTagStore — resolution reconciliation", () => { }); describe("useOnlineTagStore — the review dialog's lifecycle", () => { + // Opening it on every deferral interrupted browsing in every admin tab; a + // Review button opens it instead. + it("never opens the dialog on its own when prompts arrive", async () => { + const store = useOnlineTagStore(); + HTTP.get.mockResolvedValue(promptsResponse("a", "b")); + + // A deferral's socket notification: the path that used to open it. + store.onPromptNotification(); + await vi.waitFor(() => expect(fps(store)).toEqual(["a", "b"])); + + expect(store.promptDialogOpen).toBe(false); + }); + it("closes the dialog when the queue empties behind its back", async () => { const store = useOnlineTagStore(); store.pendingPrompts = [{ fingerprint: "a" }]; @@ -196,7 +209,7 @@ describe("useOnlineTagStore — the review dialog's lifecycle", () => { // Another tab answered the last one, or the daemon resolved it. HTTP.get.mockResolvedValue(promptsResponse()); - await store.loadPrompts({ autoOpen: false }); + await store.loadPrompts(); expect(fps(store)).toEqual([]); expect(store.promptDialogOpen).toBe(false); @@ -212,8 +225,8 @@ describe("useOnlineTagStore — the review dialog's lifecycle", () => { ); HTTP.get.mockResolvedValueOnce(promptsResponse("fresh")); - const first = store.loadPrompts({ autoOpen: false }); - await store.loadPrompts({ autoOpen: false }); + const first = store.loadPrompts(); + await store.loadPrompts(); releaseFirst(); await first; @@ -330,3 +343,35 @@ describe("useOnlineTagStore — loadSnapshot", () => { expect(HTTP.get).toHaveBeenCalledTimes(2); }); }); + +describe("useOnlineTagStore — pending comic count", () => { + it("counts comics, not questions", () => { + const store = useOnlineTagStore(); + store.pendingPrompts = [ + { fingerprint: "a", pk: 1, comics: [{ pk: 1 }, { pk: 2 }, { pk: 3 }] }, + // Names no comic at all; still one thing to look at. + { fingerprint: "b" }, + ]; + + expect(store.pendingComicCount).toBe(4); + }); + + it("is zero for an empty queue", () => { + expect(useOnlineTagStore().pendingComicCount).toBe(0); + }); +}); + +describe("pendingTitle", () => { + it("leaves the title alone with nothing to review", () => { + expect(pendingTitle(0, "Browse / Series")).toBe("Browse / Series"); + }); + + it("prefixes the count", () => { + expect(pendingTitle(3, "Admin / Tagging")).toBe("(3) Admin / Tagging"); + }); + + it("has nothing to prefix without a title", () => { + expect(pendingTitle(3, "")).toBe(""); + expect(pendingTitle(3)).toBeUndefined(); + }); +}); diff --git a/frontend/tests/unit/review-button.test.js b/frontend/tests/unit/review-button.test.js new file mode 100644 index 000000000..a8706153d --- /dev/null +++ b/frontend/tests/unit/review-button.test.js @@ -0,0 +1,78 @@ +/* + * Tests for the toolbar Review button that announces online-tagging matches. + * + * The Match Review dialog no longer opens on its own, so this button is the + * cue: admins only, only while matches wait, counting comics rather than + * questions, and a click opens the dialog. + */ +import { createTestingPinia } from "@pinia/testing"; +import { mount } from "@vue/test-utils"; +import { describe, expect, test } from "vitest"; + +import OnlineTagReviewButton from "@/components/online-tag/review-button.vue"; +import vuetify from "@/plugins/vuetify"; +import { useOnlineTagStore } from "@/stores/online-tag"; + +const SERIES_PROMPT = { + fingerprint: "a", + pk: 1, + comics: [{ pk: 1 }, { pk: 2 }, { pk: 3 }], +}; + +function mountButton({ + isStaff = true, + pendingPrompts = [SERIES_PROMPT], +} = {}) { + const pinia = createTestingPinia({ + stubActions: false, + initialState: { + auth: { user: { isStaff } }, + onlineTag: { pendingPrompts }, + }, + }); + const wrapper = mount(OnlineTagReviewButton, { + global: { plugins: [vuetify, pinia] }, + }); + return { wrapper, store: useOnlineTagStore(pinia) }; +} + +describe("OnlineTagReviewButton", () => { + test("hidden for non-admins", () => { + const { wrapper } = mountButton({ isStaff: false }); + + expect(wrapper.find(".reviewButton").exists()).toBe(false); + }); + + test("hidden while nothing waits for review", () => { + const { wrapper } = mountButton({ pendingPrompts: [] }); + + expect(wrapper.find(".reviewButton").exists()).toBe(false); + }); + + test("counts every comic a series-level prompt covers", () => { + const { wrapper } = mountButton(); + + const button = wrapper.find(".reviewButton"); + expect(button.text()).toContain("Review"); + expect(button.text()).toContain("3"); + expect(button.attributes("aria-label")).toBe( + "Review 3 online tagging matches", + ); + }); + + test("click opens the Match Review dialog", async () => { + const { wrapper, store } = mountButton(); + + await wrapper.find(".reviewButton").trigger("click"); + + expect(store.promptDialogOpen).toBe(true); + }); + + test("phones get the icon and count only", () => { + const { wrapper } = mountButton(); + + const word = wrapper.find(".reviewButton .d-none.d-sm-inline"); + expect(word.exists()).toBe(true); + expect(word.text()).toBe("Review"); + }); +});