From 2ed2e013b45c6a4f074ea2d92751115bffb05eb0 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 13:53:03 -0400 Subject: [PATCH 01/18] docs(specs): add design for TMDB-backed request section in search Adds the design for surfacing requestable TMDB results inside the main catalog search (Cmd+K dialog and full results page) as a clearly delimited "Request to Add" section that never blocks or displaces library results. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-25-search-request-section-design.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-25-search-request-section-design.md diff --git a/docs/superpowers/specs/2026-05-25-search-request-section-design.md b/docs/superpowers/specs/2026-05-25-search-request-section-design.md new file mode 100644 index 00000000..89ab119d --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-search-request-section-design.md @@ -0,0 +1,79 @@ +# Search Request Section Design + +## Goal + +Surface TMDB-backed "requestable" results inside the main catalog search so users can discover and request items that aren't in their library without leaving the search flow. The library remains the primary surface; requestable results are an additive, clearly delimited section that never blocks or displaces library results. + +## Behavior + +### Layout (both surfaces) + +- The library section renders first using existing FTS results. No changes to library ranking, pagination, or row layout. +- A "Request to Add" section renders below the library results when: + - admin `RequestsEnabled = true`, AND + - the viewer has a profile, AND + - the TMDB query returns at least one result that is not already in the library. +- The Cmd+K search dialog (`GlobalSearch`) shows up to 4 TMDB rows beneath a single "Not in your library?" CTA strip. +- The full search results page (`Catalog`) shows a section divider, a section header, then a grid of up to 20 TMDB cards on initial render. +- Clicking any TMDB row or card navigates to the existing `/requests/{media_type}/{tmdb_id}` detail page. The detail page is responsible for the actual request action and confirmation. + +### Section header copy + +- When library has ≥1 hit: header reads "Request to Add". +- When library has 0 hits and TMDB has ≥1 hit: header is replaced by a soft framing — "Not in your library, but you can request" — and there is no separate empty state for library. +- When both sources return 0 results: the existing "No matches" / "No items found" empty state is unchanged; no requestable section renders. + +### Quota / blocked viewers + +- If the viewer is quota-exhausted or individually blocked, the section still renders, but each row's request affordance is disabled with a tooltip explaining the reason. Rows remain clickable and still navigate to the detail page. + +### Performance + +- Library results never wait on TMDB. The two queries fire concurrently from the client; the library section paints as soon as FTS returns. +- TMDB query is debounced at 400ms; library query stays at the current 200ms. +- TMDB query is cancelled in-flight when the query string changes, using the existing react-query `{ signal }` pattern. +- TMDB error or timeout silently omits the section; no error banner. +- React-query `staleTime`: 5 minutes for TMDB results (reduces external calls and respects TMDB rate limits), 60 seconds for library results (matches the existing `GlobalSearch` preview). + +## Architecture + +- No backend changes to existing endpoints. The frontend coordinates two parallel queries. +- Library on the results page: existing `useCatalogWindow` against `/api/v1/catalog?source=query`. +- Library in the Cmd+K dialog: existing `previewQuery` pattern using `fetchCatalogPage` against the same endpoint. +- TMDB: existing `useRequestSearch` hook against `/api/v1/requests/search`, used by both surfaces. +- Deduplication is handled server-side by the existing `enrichPage()` → `presence.Lookup()` flow on `/requests/search`. Client filters TMDB results where `availability == "available"` so they don't shadow library rows. +- A new `useCanRequest()` hook centralizes the gating logic. It reads admin settings (`RequestsEnabled`) and viewer policy (`EffectivePolicy.LimitMode`, quota state) and returns `{ enabled, disabledReason }`. When `enabled === false`, the TMDB query is not fired. + +## Components + +- `web/src/hooks/useCanRequest.ts` (new): exposes `{ enabled, disabledReason }` derived from settings + viewer policy. +- `web/src/components/RequestToAddSection.tsx` (new): renders the section in two variants: + - `variant="dialog"` — compact row layout for `GlobalSearch`. + - `variant="grid"` — poster grid using existing `RequestPosterCard` for `Catalog`. +- `web/src/components/GlobalSearch.tsx` (modified): wires the second query, passes results into `RequestToAddSection` with `variant="dialog"`. +- `web/src/pages/Catalog.tsx` (modified): renders `RequestToAddSection` with `variant="grid"` below the existing `ItemGrid` when the source is `query`. + +## Edge cases + +- TMDB returns only items already available in the library: section is omitted (after client filter). +- Library has hits but TMDB is still loading: library renders immediately; section shows a compact skeleton in its slot, then either renders or vanishes. +- Library has 0 hits and TMDB is still pending: the page suppresses the "No matches" empty state and shows a single loading indicator until TMDB resolves. Only after TMDB returns 0 (or errors) does the empty state render. +- TMDB query never fires (gated off): library follows its existing behavior including the standard empty state. +- Viewer logs out / profile changes mid-query: `useCanRequest()` re-evaluates and cancels the TMDB query if it becomes ineligible. +- Source is not `query` (e.g., `favorites`, `watchlist`, `history`, `section`): section never renders. + +## Out of scope + +- Backend changes to `/api/v1/catalog` or any merged endpoint. +- Inline request submission from search results (the detail page continues to own request creation). +- Surfacing requestable results in any non-search context (home, library browse, etc.). +- Person / cast results from TMDB. Only movie and series results are shown. + +## Verification + +Commands assume the repository root is the cwd. + +- `cd web && pnpm run lint` +- `cd web && pnpm run format:check` +- Frontend component tests for `GlobalSearch`, `Catalog`, and `RequestToAddSection` covering: library-only results, library + TMDB, TMDB-only (library empty), both-empty, TMDB error, quota-disabled viewer, requests-globally-off. +- Manual smoke in the dev frontend: confirm library results are not delayed when TMDB is slow or errors; confirm the dialog and full-page surfaces both show the section under matching conditions. From 94b47fdf42de49b50f9a74754ad42cb653b1de81 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 14:19:37 -0400 Subject: [PATCH 02/18] docs(specs): address Codex adversarial review for search request section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits discovery eligibility from submission eligibility so blocked and quota-exhausted viewers still see the requestable section with disabled per-row CTAs, matching the documented behavior. Documents the required extensions to useRequestSearch — signal forwarding, viewer-identity-keyed cache, and invalidation on auth/profile/ settings/limit changes — so the planned 5-minute staleTime is safe and cancellation works as described. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-25-search-request-section-design.md | 44 +++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-05-25-search-request-section-design.md b/docs/superpowers/specs/2026-05-25-search-request-section-design.md index 89ab119d..bc15b977 100644 --- a/docs/superpowers/specs/2026-05-25-search-request-section-design.md +++ b/docs/superpowers/specs/2026-05-25-search-request-section-design.md @@ -25,31 +25,54 @@ Surface TMDB-backed "requestable" results inside the main catalog search so user ### Quota / blocked viewers -- If the viewer is quota-exhausted or individually blocked, the section still renders, but each row's request affordance is disabled with a tooltip explaining the reason. Rows remain clickable and still navigate to the detail page. +Discovery eligibility (whether the TMDB query fires) is separate from submission eligibility (whether the row's request CTA is active): + +- **Discovery eligibility** is gated only by global/identity preconditions: admin `RequestsEnabled = true`, the viewer is authenticated, and has a profile. If any of these is false, the TMDB query does not fire and the section is not rendered. +- **Submission eligibility** is per-viewer policy: quota-exhausted, individually blocked (`UserLimit.LimitMode = "blocked"`), or otherwise restricted. When discovery is allowed but submission is not, the section still renders, each row's request affordance is disabled, and a tooltip surfaces the reason. Rows remain clickable and still navigate to the detail page, which is responsible for displaying the full policy state. + +This keeps search-side UX consistent with what the detail page would show for the same viewer. ### Performance - Library results never wait on TMDB. The two queries fire concurrently from the client; the library section paints as soon as FTS returns. - TMDB query is debounced at 400ms; library query stays at the current 200ms. -- TMDB query is cancelled in-flight when the query string changes, using the existing react-query `{ signal }` pattern. +- TMDB query is cancelled in-flight when the query string changes. This requires extending `useRequestSearch` to accept and forward `{ signal }` to `api` (it does not today); see Architecture. - TMDB error or timeout silently omits the section; no error banner. -- React-query `staleTime`: 5 minutes for TMDB results (reduces external calls and respects TMDB rate limits), 60 seconds for library results (matches the existing `GlobalSearch` preview). +- React-query `staleTime`: 5 minutes for TMDB results (reduces external calls and respects TMDB rate limits), 60 seconds for library results (matches the existing `GlobalSearch` preview). The 5-minute window is only safe because the cache key includes viewer identity (see Architecture); cross-viewer reuse is impossible. ## Architecture - No backend changes to existing endpoints. The frontend coordinates two parallel queries. - Library on the results page: existing `useCatalogWindow` against `/api/v1/catalog?source=query`. - Library in the Cmd+K dialog: existing `previewQuery` pattern using `fetchCatalogPage` against the same endpoint. -- TMDB: existing `useRequestSearch` hook against `/api/v1/requests/search`, used by both surfaces. +- TMDB: existing `useRequestSearch` hook against `/api/v1/requests/search`, used by both surfaces — see required extensions below. - Deduplication is handled server-side by the existing `enrichPage()` → `presence.Lookup()` flow on `/requests/search`. Client filters TMDB results where `availability == "available"` so they don't shadow library rows. -- A new `useCanRequest()` hook centralizes the gating logic. It reads admin settings (`RequestsEnabled`) and viewer policy (`EffectivePolicy.LimitMode`, quota state) and returns `{ enabled, disabledReason }`. When `enabled === false`, the TMDB query is not fired. + +### Gating hook (`useCanRequest`) + +The new hook splits its return into two independent signals: + +- `discoveryEnabled: boolean` — true when admin `RequestsEnabled = true` AND the viewer is authenticated with a profile. This is the only signal that controls whether the TMDB query fires. +- `submitDisabledReason: string | null` — null when the viewer can submit; otherwise one of `"blocked"`, `"quota_exhausted"`, or a future reason key. Passed through `RequestToAddSection` to per-row UI to disable the request CTA and populate its tooltip. + +Per-viewer policy state (`EffectivePolicy.LimitMode`, quota counters) feeds `submitDisabledReason` and is never used to suppress the query. + +### `useRequestSearch` extensions + +The existing hook is reused but must be extended before it can back this feature safely: + +- **Pass through `{ signal }`**: the query function currently does not accept the react-query `signal`. Update it to accept the signal and forward it to `api` so in-flight TMDB requests are cancelled on query change, unmount, or viewer change. +- **Key by viewer identity**: extend `requestKeys.search(...)` to include the active `profile_id` (and `user_id` if profile alone is insufficient to identify the policy holder). This prevents cached results from being served across viewer changes and makes the 5-minute `staleTime` safe. +- **Invalidate on policy or identity change**: invalidate `requestKeys.search()` queries when any of the following occurs in the SPA: login/logout, profile switch, admin `RequestsEnabled` toggle, `UserLimit` mutation affecting the current viewer, or quota reset/refresh. The invalidation hooks live alongside the existing auth/profile/settings stores. ## Components -- `web/src/hooks/useCanRequest.ts` (new): exposes `{ enabled, disabledReason }` derived from settings + viewer policy. +- `web/src/hooks/useCanRequest.ts` (new): exposes `{ discoveryEnabled, submitDisabledReason }` derived from settings + viewer identity + policy as described in Architecture. +- `web/src/hooks/queries/useRequests.ts` (modified): extend `useRequestSearch` and `requestKeys.search(...)` to accept/forward `{ signal }`, include viewer identity in the query key, and expose invalidation helpers used by the auth/profile/settings stores. - `web/src/components/RequestToAddSection.tsx` (new): renders the section in two variants: - `variant="dialog"` — compact row layout for `GlobalSearch`. - `variant="grid"` — poster grid using existing `RequestPosterCard` for `Catalog`. + Accepts `submitDisabledReason` and propagates it to per-row CTAs. - `web/src/components/GlobalSearch.tsx` (modified): wires the second query, passes results into `RequestToAddSection` with `variant="dialog"`. - `web/src/pages/Catalog.tsx` (modified): renders `RequestToAddSection` with `variant="grid"` below the existing `ItemGrid` when the source is `query`. @@ -58,8 +81,9 @@ Surface TMDB-backed "requestable" results inside the main catalog search so user - TMDB returns only items already available in the library: section is omitted (after client filter). - Library has hits but TMDB is still loading: library renders immediately; section shows a compact skeleton in its slot, then either renders or vanishes. - Library has 0 hits and TMDB is still pending: the page suppresses the "No matches" empty state and shows a single loading indicator until TMDB resolves. Only after TMDB returns 0 (or errors) does the empty state render. -- TMDB query never fires (gated off): library follows its existing behavior including the standard empty state. -- Viewer logs out / profile changes mid-query: `useCanRequest()` re-evaluates and cancels the TMDB query if it becomes ineligible. +- TMDB query never fires (discovery gated off): library follows its existing behavior including the standard empty state. +- Viewer logs out, switches profile, or admin disables `RequestsEnabled` mid-query: `useCanRequest()` re-evaluates and `discoveryEnabled` flips to false; the in-flight TMDB request is cancelled via its forwarded `signal`, and cached entries under the previous viewer identity are invalidated so they cannot be re-served. +- Admin updates `UserLimit` for the current viewer while results are cached: the settings/limit mutation triggers a `requestKeys.search()` invalidation; the next paint re-fetches with the new `submitDisabledReason`. - Source is not `query` (e.g., `favorites`, `watchlist`, `history`, `section`): section never renders. ## Out of scope @@ -75,5 +99,7 @@ Commands assume the repository root is the cwd. - `cd web && pnpm run lint` - `cd web && pnpm run format:check` -- Frontend component tests for `GlobalSearch`, `Catalog`, and `RequestToAddSection` covering: library-only results, library + TMDB, TMDB-only (library empty), both-empty, TMDB error, quota-disabled viewer, requests-globally-off. +- Frontend component tests for `GlobalSearch`, `Catalog`, and `RequestToAddSection` covering: library-only results, library + TMDB, TMDB-only (library empty), both-empty, TMDB error, blocked viewer (section renders, CTAs disabled), quota-exhausted viewer (section renders, CTAs disabled), requests-globally-off (no TMDB query fired, no section). +- Hook tests for `useCanRequest` across the matrix of `RequestsEnabled`, auth state, profile presence, and policy states, asserting that `discoveryEnabled` and `submitDisabledReason` are independent. +- Hook/integration tests for the extended `useRequestSearch`: confirm `signal` forwarding cancels in-flight requests on query change, confirm cache entries are not shared across `profile_id` keys, and confirm the relevant store mutations invalidate `requestKeys.search()`. - Manual smoke in the dev frontend: confirm library results are not delayed when TMDB is slow or errors; confirm the dialog and full-page surfaces both show the section under matching conditions. From 91dc896b430a21cea69925d122a400141d48daed Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 14:35:10 -0400 Subject: [PATCH 03/18] docs(plans): add implementation plan for search request section Twelve TDD tasks covering: api() signal contract test, useCanRequest hook, viewer-keyed requestKeys.search, useRequestSearch extension (signal + viewer key + 5min staleTime + enabled override), invalidation cascade tests, RequestPosterCard optional onRequest, RequestToAddSection component (dialog + grid variants), GlobalSearch and Catalog wiring with empty-state suppression for the library-0/TMDB-pending edge case, final lint/test pass, and manual smoke. Notes a single deviation from the spec: submitDisabledReason is null in the initial implementation, with per-row request data driving disabled UI. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-25-search-request-section.md | 1858 +++++++++++++++++ 1 file changed, 1858 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-25-search-request-section.md diff --git a/docs/superpowers/plans/2026-05-25-search-request-section.md b/docs/superpowers/plans/2026-05-25-search-request-section.md new file mode 100644 index 00000000..29a72ed5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-search-request-section.md @@ -0,0 +1,1858 @@ +# Search Request Section Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a TMDB-backed "Request to Add" section beneath library results in both the Cmd+K search dialog (`GlobalSearch`) and the Catalog search results page, so users can discover and request items missing from the library without leaving the search flow. + +**Architecture:** No backend changes. Frontend fires two parallel react-query queries — library FTS via the existing `/api/v1/catalog` endpoint, and TMDB via the existing `/api/v1/requests/search` endpoint. A new `useCanRequest()` hook gates whether the TMDB query fires (admin `RequestsEnabled` + authenticated viewer with a profile). Per-row UI state (blocked / quota / pending / etc.) is driven by the backend-enriched `request.requestable` and `request.reason` fields already returned per result. The existing `useRequestSearch` hook is extended to forward `AbortSignal`, key its cache by viewer identity, and be invalidated on auth/profile/policy mutations. + +**Tech Stack:** React 18, TypeScript, vitest, @tanstack/react-query, react-router, Tailwind. All changes are in `web/` (Go backend untouched). + +**Reference spec:** `docs/superpowers/specs/2026-05-25-search-request-section-design.md` + +--- + +## File Structure + +**New files:** + +- `web/src/hooks/useCanRequest.ts` — gating hook returning `{ discoveryEnabled, submitDisabledReason }`. +- `web/src/hooks/useCanRequest.test.ts` — hook unit tests. +- `web/src/components/RequestToAddSection.tsx` — shared section component with `variant="dialog"` and `variant="grid"`. +- `web/src/components/RequestToAddSection.test.tsx` — component tests. + +**Modified files:** + +- `web/src/api/client.ts` — extend `api()` to forward `AbortSignal` from `RequestInit`. +- `web/src/hooks/queries/keys.ts` — extend `requestKeys.search()` to include viewer key. +- `web/src/hooks/queries/useRequests.ts` — extend `useRequestSearch` to accept `signal`, include viewer in key, and add invalidation helpers; wire invalidation into existing settings/limit mutations. +- `web/src/components/RequestPosterCard.tsx` — make `onRequest` and `isSubmitting` optional on `DiscoverProps`; suppress the hover Request button when `onRequest` is undefined. +- `web/src/components/GlobalSearch.tsx` — wire the second query and render `RequestToAddSection` with `variant="dialog"`. +- `web/src/components/GlobalSearch.test.tsx` — add tests for the new section behavior. +- `web/src/pages/Catalog.tsx` — render `RequestToAddSection` with `variant="grid"` below the existing `ItemGrid` when `source === "query"`. +- `web/src/pages/Catalog.test.ts` (or `.tsx` if new) — add tests for the section behavior in the full-page surface. + +--- + +## Design notes on `submitDisabledReason` + +The spec calls for `useCanRequest()` to return `submitDisabledReason: string | null`. The backend already enriches each TMDB result with per-row `request.requestable: boolean` and `request.reason?: string` via `enrichPage()` → `presence.Lookup()`. That per-row data is the canonical source of truth for the disabled state. The viewer-level field is included in the hook's return type for spec conformance and future use, but its value is `null` in this implementation. Per-row UI uses the result's own `request.requestable` and `request.reason` directly. This is consistent with the existing `RequestPosterCard` which already renders a "blocked" StatusRibbon when a row is not requestable. + +--- + +## Task 1: Pin `api()` `AbortSignal` forwarding via test + +**Files:** +- Create: `web/src/api/client.test.ts` + +`api()` at `web/src/api/client.ts:337` calls `fetch(\`/api/v1${path}\`, { ...options, headers })`. The `...options` spread already forwards `signal` to `fetch`, so behavior is correct today. This task does NOT change behavior — it adds a regression test that locks in the contract so a future refactor cannot accidentally drop signal forwarding. + +- [ ] **Step 1: Write the test** + +Create `web/src/api/client.test.ts`: + +```typescript +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { api } from "./client"; + +describe("api()", () => { + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("forwards AbortSignal from options to fetch", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + global.fetch = fetchMock as unknown as typeof fetch; + + const controller = new AbortController(); + await api("/test", { signal: controller.signal }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const call = fetchMock.mock.calls[0]!; + const init = call[1] as RequestInit; + expect(init.signal).toBe(controller.signal); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `cd web && pnpm vitest run src/api/client.test.ts` +Expected: PASS — the existing `...options` spread already forwards `signal`. No code change required. + +- [ ] **Step 3: Commit** + +```bash +git add web/src/api/client.test.ts +git commit -m "test(api): pin AbortSignal forwarding contract on api()" +``` + +--- + +## Task 2: Create `useCanRequest()` gating hook + +**Files:** +- Create: `web/src/hooks/useCanRequest.ts` +- Create: `web/src/hooks/useCanRequest.test.ts` + +`useCanRequest()` reads `useRequestFeatureStatus()` and `useCurrentProfile()` and returns `{ discoveryEnabled, submitDisabledReason }`. Discovery is enabled only when the admin flag is on AND there is a profile loaded. Per the design note above, `submitDisabledReason` is always `null` in this implementation — per-row data drives the actual UI. + +- [ ] **Step 1: Write the failing test** + +Create `web/src/hooks/useCanRequest.test.ts`: + +```typescript +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mocks = vi.hoisted(() => ({ + useRequestFeatureStatus: vi.fn(), + useCurrentProfile: vi.fn(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestFeatureStatus: () => mocks.useRequestFeatureStatus(), +})); + +vi.mock("@/hooks/useCurrentProfile", () => ({ + useCurrentProfile: () => mocks.useCurrentProfile(), +})); + +import { useCanRequest } from "./useCanRequest"; + +function CaptureHook({ onResult }: { onResult: (r: ReturnType) => void }) { + const result = useCanRequest(); + onResult(result); + return null; +} + +function render(child: ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup({child}); +} + +describe("useCanRequest", () => { + it("returns discoveryEnabled=false when requests_enabled is false", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: { requests_enabled: false } }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } }); + + let captured: ReturnType | null = null; + render( { captured = r; }} />); + + expect(captured).toEqual({ discoveryEnabled: false, submitDisabledReason: null }); + }); + + it("returns discoveryEnabled=false when there is no profile", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: { requests_enabled: true } }); + mocks.useCurrentProfile.mockReturnValue({ profile: null }); + + let captured: ReturnType | null = null; + render( { captured = r; }} />); + + expect(captured).toEqual({ discoveryEnabled: false, submitDisabledReason: null }); + }); + + it("returns discoveryEnabled=true when requests are enabled and there is a profile", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: { requests_enabled: true } }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } }); + + let captured: ReturnType | null = null; + render( { captured = r; }} />); + + expect(captured).toEqual({ discoveryEnabled: true, submitDisabledReason: null }); + }); + + it("returns discoveryEnabled=false while the feature status is still loading", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: undefined }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } }); + + let captured: ReturnType | null = null; + render( { captured = r; }} />); + + expect(captured).toEqual({ discoveryEnabled: false, submitDisabledReason: null }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web && pnpm vitest run src/hooks/useCanRequest.test.ts` +Expected: FAIL — `useCanRequest` does not exist yet. + +- [ ] **Step 3: Create the hook** + +Create `web/src/hooks/useCanRequest.ts`: + +```typescript +import { useCurrentProfile } from "@/hooks/useCurrentProfile"; +import { useRequestFeatureStatus } from "@/hooks/queries/useRequests"; + +export interface CanRequestState { + discoveryEnabled: boolean; + submitDisabledReason: string | null; +} + +export function useCanRequest(): CanRequestState { + const status = useRequestFeatureStatus(); + const { profile } = useCurrentProfile(); + const discoveryEnabled = Boolean(status.data?.requests_enabled) && Boolean(profile?.id); + return { + discoveryEnabled, + submitDisabledReason: null, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd web && pnpm vitest run src/hooks/useCanRequest.test.ts` +Expected: PASS, all four cases. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/hooks/useCanRequest.ts web/src/hooks/useCanRequest.test.ts +git commit -m "feat(hooks): add useCanRequest gating hook for discovery eligibility" +``` + +--- + +## Task 3: Extend `requestKeys.search` to include viewer identity + +**Files:** +- Modify: `web/src/hooks/queries/keys.ts:135-136` + +Add a `viewerKey` parameter so the cache cannot serve results across viewer changes. + +- [ ] **Step 1: Update the key shape** + +Open `web/src/hooks/queries/keys.ts` and replace lines 135-136: + +```typescript + search: (mediaType: string, query: string, page: number, viewerKey: string) => + ["requests", "search", viewerKey, mediaType, query, page] as const, +``` + +- [ ] **Step 2: Run the type check to see callers that need updating** + +Run: `cd web && pnpm tsc --noEmit` +Expected: TypeScript errors at every call site of `requestKeys.search(...)`. Note the file paths reported. + +- [ ] **Step 3: Commit the key change alone** + +The next task updates the callers. Keep this commit focused. + +```bash +git add web/src/hooks/queries/keys.ts +git commit -m "refactor(keys): add viewerKey to requestKeys.search" +``` + +--- + +## Task 4: Extend `useRequestSearch` with signal, viewer key, staleTime, and enabled option + +**Files:** +- Modify: `web/src/hooks/queries/useRequests.ts:151-166` + +Update `useRequestSearch` so it (a) accepts and forwards a `signal` from react-query, (b) keys the cache by the current viewer's `profile.id`, (c) uses a 5-minute `staleTime` (the spec value), and (d) accepts an optional `enabled` override so callers can gate it on `discoveryEnabled` without firing the query when disallowed. + +- [ ] **Step 1: Write the failing test** + +Append to `web/src/hooks/queries/useRequests.test.ts` (create the file if missing): + +```typescript +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mocks = vi.hoisted(() => ({ + useQuery: vi.fn(), + useCurrentProfile: vi.fn(), + api: vi.fn(), +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = + await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useQuery: (...args: unknown[]) => mocks.useQuery(...args), + }; +}); + +vi.mock("@/hooks/useCurrentProfile", () => ({ + useCurrentProfile: () => mocks.useCurrentProfile(), +})); + +vi.mock("@/api/client", () => ({ + api: (...args: unknown[]) => mocks.api(...args), +})); + +import { useRequestSearch } from "./useRequests"; + +function render(node: React.ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup({node}); +} + +function CallHook(props: { mediaType: "movie" | "series" | "all"; q: string; page?: number }) { + useRequestSearch(props.mediaType, props.q, props.page ?? 1); + return null; +} + +describe("useRequestSearch", () => { + it("includes the current profile id in the query key", () => { + mocks.useQuery.mockReset(); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "profile-1" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { queryKey: readonly unknown[] }; + expect(options.queryKey).toEqual(["requests", "search", "profile-1", "all", "dune", 1]); + }); + + it("uses 'anon' as the viewer key when there is no profile", () => { + mocks.useQuery.mockReset(); + mocks.useCurrentProfile.mockReturnValue({ profile: null }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { queryKey: readonly unknown[] }; + expect(options.queryKey).toEqual(["requests", "search", "anon", "movie", "dune", 1]); + }); + + it("forwards the react-query signal to api()", async () => { + mocks.useQuery.mockReset(); + mocks.api.mockResolvedValue({ page: 1, total_pages: 0, total_results: 0, results: [] }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "profile-1" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { + queryFn: (ctx: { signal: AbortSignal }) => unknown; + }; + const controller = new AbortController(); + await options.queryFn({ signal: controller.signal }); + + expect(mocks.api).toHaveBeenCalledTimes(1); + const apiCall = mocks.api.mock.calls[0]!; + expect(apiCall[0]).toContain("/requests/search?"); + const init = apiCall[1] as RequestInit; + expect(init.signal).toBe(controller.signal); + }); + + it("uses a 5-minute staleTime", () => { + mocks.useQuery.mockReset(); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { staleTime: number }; + expect(options.staleTime).toBe(5 * 60 * 1000); + }); + + it("respects the enabled option override", () => { + mocks.useQuery.mockReset(); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } }); + + function CallHookWithOpt({ enabled }: { enabled: boolean }) { + useRequestSearch("all", "dune", 1, { enabled }); + return null; + } + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean }; + expect(options.enabled).toBe(false); + }); + + it("does not include enabled override when option omitted (defaults to true)", () => { + mocks.useQuery.mockReset(); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean }; + // Internally `normalizedQuery.length > 1` is true, and the default enabled override + // is true, so this should resolve to true. + expect(options.enabled).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web && pnpm vitest run src/hooks/queries/useRequests.test.ts` +Expected: FAIL — the existing hook does not include profile in the key, does not pass signal, and uses `REQUESTS_STALE_TIME` (30s). + +- [ ] **Step 3: Update the hook** + +Replace lines 151-166 of `web/src/hooks/queries/useRequests.ts` with: + +```typescript +import { useCurrentProfile } from "@/hooks/useCurrentProfile"; + +const REQUEST_SEARCH_STALE_TIME = 5 * 60 * 1000; + +export interface UseRequestSearchOptions { + /** When false, suppresses the query regardless of the query string. Default: true. */ + enabled?: boolean; +} + +export function useRequestSearch( + mediaType: RequestSearchMediaType, + query: string, + page = 1, + options: UseRequestSearchOptions = {}, +) { + const normalizedQuery = query.trim(); + const { profile } = useCurrentProfile(); + const viewerKey = profile?.id ?? "anon"; + const enabledOverride = options.enabled ?? true; + return useQuery({ + queryKey: requestKeys.search(mediaType, normalizedQuery, page, viewerKey), + queryFn: ({ signal }) => { + const params = new URLSearchParams({ + q: normalizedQuery, + media_type: mediaType, + page: String(page), + }); + return api(`/requests/search?${params}`, { signal }); + }, + enabled: enabledOverride && normalizedQuery.length > 1, + staleTime: REQUEST_SEARCH_STALE_TIME, + }); +} +``` + +Note: the `useCurrentProfile` import must be added near the top of the file. The `REQUEST_SEARCH_STALE_TIME` constant goes near the top alongside `REQUESTS_STALE_TIME`. Existing callers (e.g., `Requests.tsx:140`) pass three arguments and continue to work — the new fourth `options` parameter defaults to `{}`. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd web && pnpm vitest run src/hooks/queries/useRequests.test.ts` +Expected: PASS, all four cases. + +- [ ] **Step 5: Run the full type check** + +Run: `cd web && pnpm tsc --noEmit` +Expected: PASS. No call sites should break (this hook's external signature is unchanged). + +- [ ] **Step 6: Commit** + +```bash +git add web/src/hooks/queries/useRequests.ts web/src/hooks/queries/useRequests.test.ts +git commit -m "feat(requests): key useRequestSearch by viewer, forward signal, raise staleTime" +``` + +--- + +## Task 5: Invalidate request search cache on policy & settings mutations + +**Files:** +- Modify: `web/src/hooks/queries/useRequests.ts:53-56` (extend `invalidateRequestSurfaces`) +- Modify: `web/src/hooks/queries/useRequests.ts:262-288` (`useUpdateRequestSettings`) +- Modify: `web/src/hooks/queries/useRequests.ts:345-362` (`useUpdateRequestUserLimit`) + +The existing `invalidateRequestSurfaces` invalidates `requestKeys.all`, which is `["requests"]`. React-query's invalidation matches by key prefix, so this *already* invalidates `requestKeys.search(...)` because that key starts with `["requests", "search", ...]`. Verify this and add a focused test rather than introducing new helpers. + +- [ ] **Step 1: Add a test asserting invalidation behavior** + +Append to `web/src/hooks/queries/useRequests.test.ts`: + +```typescript +import { QueryClient as RealQueryClient } from "@tanstack/react-query"; +import { requestKeys } from "./keys"; + +describe("requestKeys.all invalidation", () => { + it("invalidates entries under requestKeys.search() when invalidating requestKeys.all", async () => { + const client = new RealQueryClient(); + client.setQueryData(requestKeys.search("all", "dune", 1, "profile-1"), { sentinel: true }); + + expect(client.getQueryData(requestKeys.search("all", "dune", 1, "profile-1"))).toEqual({ + sentinel: true, + }); + + await client.invalidateQueries({ queryKey: requestKeys.all }); + + const state = client.getQueryState(requestKeys.search("all", "dune", 1, "profile-1")); + expect(state?.isInvalidated).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `cd web && pnpm vitest run src/hooks/queries/useRequests.test.ts` +Expected: PASS. This documents that the existing `invalidateRequestSurfaces` already cascades to search results. + +- [ ] **Step 3: Add a comment in useRequests.ts** + +In `web/src/hooks/queries/useRequests.ts`, replace the `invalidateRequestSurfaces` function (lines 53-56) with: + +```typescript +function invalidateRequestSurfaces(queryClient: ReturnType) { + // requestKeys.all = ["requests"] — invalidating it cascades to every nested key, + // including requestKeys.search(...). Settings and per-user limit mutations rely + // on this to re-fetch viewer-scoped search results when policy changes. + queryClient.invalidateQueries({ queryKey: requestKeys.all }); + queryClient.invalidateQueries({ queryKey: adminKeys.requestsRoot() }); +} +``` + +- [ ] **Step 4: Add a test that profile change invalidates results** + +Append to `web/src/hooks/queries/useRequests.test.ts`: + +```typescript +describe("viewer-scoped cache isolation", () => { + it("does not return profile-1 results when keyed by profile-2", () => { + const client = new RealQueryClient(); + client.setQueryData(requestKeys.search("all", "dune", 1, "profile-1"), { + results: [{ tmdb_id: 1 }], + }); + + expect(client.getQueryData(requestKeys.search("all", "dune", 1, "profile-2"))).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cd web && pnpm vitest run src/hooks/queries/useRequests.test.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add web/src/hooks/queries/useRequests.ts web/src/hooks/queries/useRequests.test.ts +git commit -m "test(requests): document viewer-keyed cache isolation and invalidation cascade" +``` + +--- + +## Task 6: Make `RequestPosterCard.DiscoverProps` request handler optional + +**Files:** +- Modify: `web/src/components/RequestPosterCard.tsx:9-16` (DiscoverProps) +- Modify: `web/src/components/RequestPosterCard.tsx:40-50` (DiscoverCard signature) +- Modify: `web/src/components/RequestPosterCard.tsx:95-120` (hover button render) + +For the new search context, we don't want the inline-submit hover button. Make `onRequest` and `isSubmitting` optional, and only render the hover button when `onRequest` is defined. + +- [ ] **Step 1: Write the failing test** + +Create `web/src/components/RequestPosterCard.test.tsx`: + +```typescript +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; +import RequestPosterCard from "./RequestPosterCard"; +import type { RequestMediaResult } from "@/api/types"; + +const requestable: RequestMediaResult = { + media_type: "movie", + tmdb_id: 42, + title: "Test Movie", + availability: "missing", + request: { requestable: true }, +}; + +describe("RequestPosterCard (discover variant)", () => { + it("renders the hover Request button when onRequest is provided", () => { + const markup = renderToStaticMarkup( + + {}} + /> + , + ); + expect(markup).toContain("Request"); + }); + + it("does not render the hover Request button when onRequest is omitted", () => { + const markup = renderToStaticMarkup( + + + , + ); + // The hover button has class "rounded-full bg-white"; check that pattern is absent. + expect(markup).not.toContain("rounded-full bg-white"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd web && pnpm vitest run src/components/RequestPosterCard.test.tsx` +Expected: FAIL — the second test fails because `RequestPosterCard` currently requires `onRequest` and `isSubmitting`, and even with placeholder values it would still render the button. + +- [ ] **Step 3: Update DiscoverProps** + +In `web/src/components/RequestPosterCard.tsx`, replace lines 9-16 with: + +```typescript +type DiscoverProps = { + variant: "discover"; + item: RequestMediaResult; + /** Called when the inline hover Request button is clicked. Omit to suppress the button. */ + onRequest?: () => void; + /** Displays the spinner state on the hover Request button. Ignored when onRequest is omitted. */ + isSubmitting?: boolean; + /** When true, fills the parent (use inside grids). Default: fixed carousel width. */ + fluid?: boolean; +}; +``` + +- [ ] **Step 4: Update the DiscoverCard component signature and render** + +Replace lines 40-50 of `RequestPosterCard.tsx`: + +```typescript +function DiscoverCard({ + item, + isSubmitting, + onRequest, + fluid, +}: { + item: RequestMediaResult; + isSubmitting?: boolean; + onRequest?: () => void; + fluid?: boolean; +}) { +``` + +Replace lines 95-120 (the conditional hover button) with: + +```typescript + {requestable && onRequest && ( +
+ +
+ )} +``` + +Also update the call site at line 30 (in the dispatcher) to spread props correctly: + +```typescript + return ( + + ); +``` + +(This is already the existing shape — verify it still type-checks now that the inner DiscoverProps fields are optional.) + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cd web && pnpm vitest run src/components/RequestPosterCard.test.tsx` +Expected: PASS, both cases. + +- [ ] **Step 6: Run the full type check** + +Run: `cd web && pnpm tsc --noEmit` +Expected: PASS. Existing callers still pass both fields, so no breaks. + +- [ ] **Step 7: Commit** + +```bash +git add web/src/components/RequestPosterCard.tsx web/src/components/RequestPosterCard.test.tsx +git commit -m "feat(request-card): make onRequest optional on discover variant" +``` + +--- + +## Task 7: Create `RequestToAddSection` — dialog variant + +**Files:** +- Create: `web/src/components/RequestToAddSection.tsx` +- Create: `web/src/components/RequestToAddSection.test.tsx` + +A self-contained component that owns: +- The TMDB query (via `useRequestSearch`) gated by `useCanRequest().discoveryEnabled` +- Filtering out results already in the library (`availability === "available"`) +- Section header copy: "Request to Add" when `libraryHadHits=true`, "Not in your library, but you can request" when `libraryHadHits=false` +- Two render variants: `dialog` (compact rows, max 4) and `grid` (poster cards, max 20) +- Silent omit on error or empty TMDB + +This task implements the dialog variant only; Task 8 adds the grid variant. + +- [ ] **Step 1: Write the failing test for the dialog variant** + +Create `web/src/components/RequestToAddSection.test.tsx`: + +```typescript +import type { ReactNode } from "react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mocks = vi.hoisted(() => ({ + useCanRequest: vi.fn(), + useRequestSearch: vi.fn(), + useDebounce: vi.fn(), +})); + +vi.mock("@/hooks/useCanRequest", () => ({ + useCanRequest: () => mocks.useCanRequest(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestSearch: (...args: unknown[]) => mocks.useRequestSearch(...args), +})); + +vi.mock("@/hooks/useDebounce", () => ({ + useDebounce: (v: T) => mocks.useDebounce(v) ?? v, +})); + +import { RequestToAddSection } from "./RequestToAddSection"; +import type { RequestMediaResult } from "@/api/types"; + +function render(child: ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup( + + {child} + , + ); +} + +const missingResult = (overrides: Partial = {}): RequestMediaResult => ({ + media_type: "movie", + tmdb_id: 1, + title: "Dune: Prophecy", + year: 2024, + availability: "missing", + request: { requestable: true }, + ...overrides, +}); + +const availableResult = (overrides: Partial = {}): RequestMediaResult => ({ + media_type: "movie", + tmdb_id: 2, + title: "Dune", + year: 2021, + availability: "available", + request: { requestable: false }, + ...overrides, +}); + +describe("RequestToAddSection (dialog variant)", () => { + beforeEach(() => { + mocks.useCanRequest.mockReset(); + mocks.useRequestSearch.mockReset(); + mocks.useDebounce.mockReset(); + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useDebounce.mockImplementation((v: unknown) => v); + }); + + it("renders nothing when discovery is disabled", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false }); + + const markup = render(); + expect(markup).toBe(""); + }); + + it("renders 'Request to Add' header when library had hits", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 1, results: [missingResult()] }, + isLoading: false, + isError: false, + }); + const markup = render(); + expect(markup).toContain("Request to Add"); + expect(markup).toContain("Dune: Prophecy"); + }); + + it("renders soft framing when library had 0 hits", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 1, results: [missingResult()] }, + isLoading: false, + isError: false, + }); + const markup = render( + , + ); + expect(markup).toContain("Not in your library, but you can request"); + expect(markup).not.toContain("Request to Add"); + }); + + it("filters out results already available in the library", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 2, + results: [availableResult(), missingResult()], + }, + isLoading: false, + isError: false, + }); + const markup = render(); + expect(markup).toContain("Dune: Prophecy"); + expect(markup).not.toContain('"Dune"'); + }); + + it("renders nothing when TMDB returned an error", () => { + mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: true }); + const markup = render(); + expect(markup).toBe(""); + }); + + it("renders nothing when all TMDB results are already in the library", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 1, results: [availableResult()] }, + isLoading: false, + isError: false, + }); + const markup = render(); + expect(markup).toBe(""); + }); + + it("limits the dialog variant to at most 4 rows", () => { + const many = Array.from({ length: 10 }, (_, i) => + missingResult({ tmdb_id: i + 100, title: `Result ${i}` }), + ); + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: many.length, results: many }, + isLoading: false, + isError: false, + }); + const markup = render(); + expect(markup).toContain("Result 0"); + expect(markup).toContain("Result 3"); + expect(markup).not.toContain("Result 4"); + }); + + it("renders the disabled affordance and reason when a row is not requestable", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + missingResult({ + tmdb_id: 7, + title: "Quota Capped Movie", + request: { requestable: false, reason: "quota_exhausted" }, + }), + ], + }, + isLoading: false, + isError: false, + }); + + const markup = render(); + + expect(markup).toContain("Quota Capped Movie"); + // The active "Request" amber chip is suppressed; a muted reason chip is shown instead. + expect(markup).not.toContain("bg-amber-400/15"); + // formatRequestReason("quota_exhausted") yields a human label that must be present. + expect(markup).toMatch(/title="[^"]+"/); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd web && pnpm vitest run src/components/RequestToAddSection.test.tsx` +Expected: FAIL — the component does not exist yet. + +- [ ] **Step 3: Create the component** + +Create `web/src/components/RequestToAddSection.tsx`: + +```typescript +import { Link } from "react-router"; +import { Film, Tv } from "lucide-react"; +import { useCanRequest } from "@/hooks/useCanRequest"; +import { useRequestSearch } from "@/hooks/queries/useRequests"; +import type { RequestMediaResult } from "@/api/types"; +import { formatRequestReason, tmdbImageURL } from "@/lib/mediaRequests"; +import { cn } from "@/lib/utils"; + +const DIALOG_LIMIT = 4; +const GRID_LIMIT = 20; + +export type RequestToAddSectionProps = { + variant: "dialog" | "grid"; + query: string; + /** True when the library FTS returned ≥1 hit. Drives header copy. */ + libraryHadHits: boolean; +}; + +export function RequestToAddSection({ variant, query, libraryHadHits }: RequestToAddSectionProps) { + const { discoveryEnabled } = useCanRequest(); + const search = useRequestSearch("all", query, 1); + + if (!discoveryEnabled) return null; + if (search.isError) return null; + + const filtered = (search.data?.results ?? []).filter( + (item) => item.availability !== "available", + ); + if (filtered.length === 0) return null; + + const limit = variant === "dialog" ? DIALOG_LIMIT : GRID_LIMIT; + const visible = filtered.slice(0, limit); + + if (variant === "dialog") { + return ; + } + return ; +} + +function HeaderCopy({ libraryHadHits, count }: { libraryHadHits: boolean; count: number }) { + if (libraryHadHits) { + return ( +
+ Request to Add + + {count} + +
+ ); + } + return ( +
+ Not in your library, but you can request: +
+ ); +} + +function DialogVariant({ + items, + libraryHadHits, +}: { + items: RequestMediaResult[]; + libraryHadHits: boolean; +}) { + return ( +
+ +
    + {items.map((item) => ( +
  • + +
  • + ))} +
+
+ ); +} + +function DialogRow({ item }: { item: RequestMediaResult }) { + const poster = tmdbImageURL(item.poster_path); + const Icon = item.media_type === "series" ? Tv : Film; + const requestable = item.request.requestable; + const reasonLabel = !requestable + ? item.request.reason + ? formatRequestReason(item.request.reason) + : "Blocked" + : null; + return ( + +
+ {poster ? ( + + ) : ( +
+ +
+ )} +
+
+
{item.title}
+
+ {item.year ? `${item.year} · ` : ""} + {item.media_type === "series" ? "Series" : "Movie"} +
+
+ {requestable ? ( + + Request + + ) : ( + + {reasonLabel} + + )} + + ); +} + +function GridVariant({ + items: _items, + libraryHadHits: _libraryHadHits, +}: { + items: RequestMediaResult[]; + libraryHadHits: boolean; +}) { + // Implemented in Task 8. + return null; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd web && pnpm vitest run src/components/RequestToAddSection.test.tsx` +Expected: PASS, all dialog-variant cases. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/components/RequestToAddSection.tsx web/src/components/RequestToAddSection.test.tsx +git commit -m "feat(search): add RequestToAddSection dialog variant" +``` + +--- + +## Task 8: Add the grid variant to `RequestToAddSection` + +**Files:** +- Modify: `web/src/components/RequestToAddSection.tsx` (`GridVariant`) +- Modify: `web/src/components/RequestToAddSection.test.tsx` (add grid coverage) + +- [ ] **Step 1: Write the failing test** + +Append to `web/src/components/RequestToAddSection.test.tsx`: + +```typescript +describe("RequestToAddSection (grid variant)", () => { + beforeEach(() => { + mocks.useCanRequest.mockReset(); + mocks.useRequestSearch.mockReset(); + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + }); + + it("renders a card per result with the Request to Add header when library had hits", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 2, + results: [ + missingResult({ tmdb_id: 1, title: "Dune: Prophecy" }), + missingResult({ tmdb_id: 2, title: "Dune (1984)" }), + ], + }, + isLoading: false, + isError: false, + }); + const markup = render(); + expect(markup).toContain("Request to Add"); + expect(markup).toContain("Dune: Prophecy"); + expect(markup).toContain("Dune (1984)"); + }); + + it("renders the soft framing in the grid variant when library had 0 hits", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [missingResult({ tmdb_id: 1, title: "Dune: Prophecy" })], + }, + isLoading: false, + isError: false, + }); + const markup = render( + , + ); + expect(markup).toContain("Not in your library, but you can request"); + }); + + it("limits the grid to at most 20 cards", () => { + const many = Array.from({ length: 30 }, (_, i) => + missingResult({ tmdb_id: i + 100, title: `Result ${i}` }), + ); + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: many.length, results: many }, + isLoading: false, + isError: false, + }); + const markup = render(); + expect(markup).toContain("Result 0"); + expect(markup).toContain("Result 19"); + expect(markup).not.toContain("Result 20"); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd web && pnpm vitest run src/components/RequestToAddSection.test.tsx` +Expected: FAIL — the grid variant renders `null`. + +- [ ] **Step 3: Implement `GridVariant`** + +Replace the `GridVariant` placeholder in `web/src/components/RequestToAddSection.tsx`: + +```typescript +import RequestPosterCard from "./RequestPosterCard"; + +function GridVariant({ + items, + libraryHadHits, +}: { + items: RequestMediaResult[]; + libraryHadHits: boolean; +}) { + return ( +
+
+
+

+ {libraryHadHits ? "Request to Add" : "Not in your library, but you can request"} +

+
+
+
+ {items.map((item) => ( + + ))} +
+
+ ); +} +``` + +(`onRequest` and `isSubmitting` are intentionally omitted — Task 6 made them optional so the hover button is suppressed.) + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd web && pnpm vitest run src/components/RequestToAddSection.test.tsx` +Expected: PASS, all dialog and grid cases. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/components/RequestToAddSection.tsx web/src/components/RequestToAddSection.test.tsx +git commit -m "feat(search): add RequestToAddSection grid variant for Catalog page" +``` + +--- + +## Task 9: Integrate `RequestToAddSection` into `GlobalSearch` + +**Files:** +- Modify: `web/src/components/GlobalSearch.tsx` +- Modify: `web/src/components/GlobalSearch.test.tsx` + +GlobalSearch hoists the TMDB query alongside the library query so it can suppress the "No matches" empty state while TMDB is still pending or has results to show. The section renders inside the same scrollable list. The TMDB debounce is 400ms (vs library's 200ms). + +- [ ] **Step 1: Write the failing tests** + +The existing `GlobalSearch.test.tsx` mocks `useQuery` globally. Because GlobalSearch now calls multiple hooks that internally use `useQuery` (library preview + TMDB search), the mock returns the same response for both. Switch the test scaffolding to mock the specific hooks we use rather than `useQuery` itself. + +Replace the top of `web/src/components/GlobalSearch.test.tsx` (the existing `mocks`, the `useQuery` mock, and the `useDebounce` mock) with: + +```typescript +const mocks = vi.hoisted(() => ({ + useQuery: vi.fn(), + useCanRequest: vi.fn(), + useRequestSearch: vi.fn(), +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = + await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useQuery: (...args: unknown[]) => mocks.useQuery(...args), + }; +}); + +vi.mock("@/hooks/useCanRequest", () => ({ + useCanRequest: () => mocks.useCanRequest(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestSearch: (...args: unknown[]) => mocks.useRequestSearch(...args), +})); + +vi.mock("@/hooks/useDebounce", () => ({ + useDebounce: (v: T) => v, +})); +``` + +Then update the `beforeEach` to set default mocks: + +```typescript + beforeEach(() => { + mocks.useQuery.mockReset(); + mocks.useCanRequest.mockReset(); + mocks.useRequestSearch.mockReset(); + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useRequestSearch.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + }); + mocks.useQuery.mockReturnValue({ + data: { total: 50, has_more: true, items: [browseFixture] }, + isFetching: false, + isError: false, + }); + }); +``` + +Now add a section-wiring `describe` block at the end of the file: + +```typescript +vi.mock("@/components/RequestToAddSection", () => ({ + RequestToAddSection: ({ + variant, + query, + libraryHadHits, + }: { + variant: string; + query: string; + libraryHadHits: boolean; + }) => ( +
+ variant={variant} query={query} libraryHadHits={String(libraryHadHits)} +
+ ), +})); + +describe("GlobalSearch + RequestToAddSection wiring", () => { + it("renders the section with libraryHadHits=true when library returned results", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { media_type: "movie", tmdb_id: 1, title: "X", availability: "missing", request: { requestable: true } }, + ], + }, + isLoading: false, + isError: false, + }); + const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" }); + + expect(markup).toContain('data-testid="request-section"'); + expect(markup).toContain('libraryHadHits="true"'); + expect(markup).toContain('variant="dialog"'); + }); + + it("renders the section with libraryHadHits=false when library returned 0 results", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useQuery.mockReturnValue({ + data: { total: 0, has_more: false, items: [] }, + isFetching: false, + isError: false, + }); + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { media_type: "movie", tmdb_id: 1, title: "X", availability: "missing", request: { requestable: true } }, + ], + }, + isLoading: false, + isError: false, + }); + const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "ThisDoesNotExist" }); + + expect(markup).toContain('libraryHadHits="false"'); + }); + + it("does not call useRequestSearch with enabled=true when discoveryEnabled is false", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" }); + + const call = mocks.useRequestSearch.mock.calls.at(-1); + expect(call?.[3]).toEqual({ enabled: false }); + }); + + it("suppresses 'No matches' when library is empty and TMDB is still loading", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useQuery.mockReturnValue({ + data: { total: 0, has_more: false, items: [] }, + isFetching: false, + isError: false, + }); + mocks.useRequestSearch.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }); + const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "Pending" }); + + expect(markup).not.toContain("No matches"); + }); + + it("suppresses 'No matches' when library is empty and TMDB has missing results", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useQuery.mockReturnValue({ + data: { total: 0, has_more: false, items: [] }, + isFetching: false, + isError: false, + }); + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { media_type: "movie", tmdb_id: 1, title: "X", availability: "missing", request: { requestable: true } }, + ], + }, + isLoading: false, + isError: false, + }); + const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "FoundOnTmdb" }); + + expect(markup).not.toContain("No matches"); + }); + + it("still shows 'No matches' when both library and TMDB are empty", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useQuery.mockReturnValue({ + data: { total: 0, has_more: false, items: [] }, + isFetching: false, + isError: false, + }); + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 0, results: [] }, + isLoading: false, + isError: false, + }); + const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "ZzzNothing" }); + + expect(markup).toContain("No matches"); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd web && pnpm vitest run src/components/GlobalSearch.test.tsx` +Expected: FAIL — the section is not yet wired in, and `useRequestSearch` is not called from GlobalSearch. + +- [ ] **Step 3: Wire the section into GlobalSearch** + +In `web/src/components/GlobalSearch.tsx`, add imports near the top: + +```typescript +import { RequestToAddSection } from "./RequestToAddSection"; +import { useCanRequest } from "@/hooks/useCanRequest"; +import { useRequestSearch } from "@/hooks/queries/useRequests"; +``` + +Add a new constant near the top with the other constants: + +```typescript +const TMDB_DEBOUNCE_MS = 400; +``` + +Inside the `GlobalSearch` component, after the existing `debouncedQuery` line, add a second debounce for TMDB and lift the TMDB query: + +```typescript + const tmdbDebouncedQuery = useDebounce(query.trim(), TMDB_DEBOUNCE_MS); + const canRequest = useCanRequest(); + const tmdbQuery = useRequestSearch("all", tmdbDebouncedQuery, 1, { + enabled: canRequest.discoveryEnabled, + }); + const tmdbMissingCount = + tmdbQuery.data?.results?.filter((r) => r.availability !== "available").length ?? 0; + const tmdbStillLoading = + canRequest.discoveryEnabled && tmdbDebouncedQuery.length > 1 && tmdbQuery.isLoading; + const tmdbWillRender = canRequest.discoveryEnabled && tmdbMissingCount > 0; +``` + +Update the existing `showEmpty` computation to suppress the empty state while TMDB might still produce a result: + +```typescript + const showEmpty = + !previewQuery.isFetching && + debouncedQuery.length > 0 && + items.length === 0 && + !previewQuery.isError && + !tmdbStillLoading && + !tmdbWillRender; +``` + +Then in the `showResultsPanel` JSX block, add the `` render below the `items.map(...)` loop. Replace lines 237-281 with: + +```typescript + {showResultsPanel && ( +
+
+ {showLoading && ( +
+ Searching... +
+ )} + {showError && ( +
+ Could not load results. Press Enter to open the search page. +
+ )} + {showEmpty && ( +
+ No matches +
+ )} + {items.map((item, i) => ( + + ))} + {tmdbDebouncedQuery.length > 1 && ( + 0} + /> + )} +
+
+ {items.length} results found +
+
+ {total > PREVIEW_LIMIT ? ( +

+ Showing top {PREVIEW_LIMIT} of {total}. Press Enter for all results. +

+ ) : ( +

Press Enter to open the full search page.

+ )} +
+
+ )} +``` + +Note that `RequestToAddSection` ALSO calls `useRequestSearch` internally — react-query dedupes by query key, so this is a single network call. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd web && pnpm vitest run src/components/GlobalSearch.test.tsx` +Expected: PASS, including the existing tests plus the new section-wiring tests. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/components/GlobalSearch.tsx web/src/components/GlobalSearch.test.tsx +git commit -m "feat(search): render RequestToAddSection in the Cmd+K dialog with empty-state suppression" +``` + +--- + +## Task 10: Integrate `RequestToAddSection` into `Catalog` + +**Files:** +- Modify: `web/src/pages/Catalog.tsx` +- Create: `web/src/pages/Catalog.test.tsx` (if not present) + +Add the grid variant below the existing `ItemGrid` when `state.source === "query"` and there is a query. The page also lifts the TMDB query so it can keep the `ItemGrid` in a loading state (instead of showing "No items found") while TMDB is still pending or has missing results. + +- [ ] **Step 1: Write the failing tests** + +Inspect `web/src/pages/`. If a `Catalog.test.tsx` already exists, append to it; otherwise create it. + +```typescript +import type { ReactNode } from "react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mocks = vi.hoisted(() => ({ + useCatalogWindow: vi.fn(), + useCanRequest: vi.fn(), + useRequestSearch: vi.fn(), +})); + +vi.mock("@/hooks/queries/catalog", () => ({ + useCatalogWindow: (...args: unknown[]) => mocks.useCatalogWindow(...args), + createCatalogSearchState: (source: string, params: Record) => ({ + source, + ...params, + }), + fetchCatalogPage: vi.fn(), +})); + +vi.mock("@/hooks/useCanRequest", () => ({ + useCanRequest: () => mocks.useCanRequest(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestSearch: (...args: unknown[]) => mocks.useRequestSearch(...args), +})); + +vi.mock("@/components/RequestToAddSection", () => ({ + RequestToAddSection: ({ + variant, + query, + libraryHadHits, + }: { + variant: string; + query: string; + libraryHadHits: boolean; + }) => ( +
+ variant={variant} query={query} libraryHadHits={String(libraryHadHits)} +
+ ), +})); + +vi.mock("@/components/ItemGrid", () => ({ + default: ({ totalItems, loading }: { totalItems: number; loading: boolean }) => ( +
+ ), +})); + +import Catalog from "./Catalog"; + +function render(initialEntry: string) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("Catalog + RequestToAddSection wiring", () => { + beforeEach(() => { + mocks.useCatalogWindow.mockReset(); + mocks.useCanRequest.mockReset(); + mocks.useRequestSearch.mockReset(); + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false }); + }); + + it("renders the grid variant when source=query and library has results", () => { + mocks.useCatalogWindow.mockReturnValue({ + data: { + title: 'Results for "dune"', + totalItems: 2, + pages: new Map([[0, [{ content_id: "lib-1", title: "Dune", type: "movie", year: 2021 }]]]), + }, + isLoading: false, + }); + + const markup = render("/catalog?source=query&q=dune"); + + expect(markup).toContain('data-testid="request-section"'); + expect(markup).toContain('variant="grid"'); + expect(markup).toContain('libraryHadHits="true"'); + }); + + it("renders the grid variant with libraryHadHits=false when library has 0 hits", () => { + mocks.useCatalogWindow.mockReturnValue({ + data: { title: 'Results for "noresults"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + + const markup = render("/catalog?source=query&q=noresults"); + + expect(markup).toContain('libraryHadHits="false"'); + }); + + it("does not render the section when source is not query", () => { + mocks.useCatalogWindow.mockReturnValue({ + data: { title: "Favorites", totalItems: 0, pages: new Map() }, + isLoading: false, + }); + + const markup = render("/catalog?source=favorites"); + expect(markup).not.toContain('data-testid="request-section"'); + }); + + it("passes enabled=false to useRequestSearch when discoveryEnabled is false", () => { + mocks.useCatalogWindow.mockReturnValue({ + data: { title: 'Results for "dune"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + + render("/catalog?source=query&q=dune"); + + const call = mocks.useRequestSearch.mock.calls.at(-1); + expect(call?.[3]).toEqual({ enabled: false }); + }); + + it("keeps ItemGrid in a loading state when library is empty and TMDB is still loading", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCatalogWindow.mockReturnValue({ + data: { title: 'Results for "dune"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: true, isError: false }); + + const markup = render("/catalog?source=query&q=dune"); + + expect(markup).toContain('data-loading="true"'); + }); + + it("keeps ItemGrid in a loading state when library is empty and TMDB has missing results", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCatalogWindow.mockReturnValue({ + data: { title: 'Results for "dune"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { media_type: "movie", tmdb_id: 1, title: "X", availability: "missing", request: { requestable: true } }, + ], + }, + isLoading: false, + isError: false, + }); + + const markup = render("/catalog?source=query&q=dune"); + + expect(markup).toContain('data-loading="true"'); + }); + + it("renders the normal ItemGrid empty state when both library and TMDB are empty", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCatalogWindow.mockReturnValue({ + data: { title: 'Results for "zzz"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 0, results: [] }, + isLoading: false, + isError: false, + }); + + const markup = render("/catalog?source=query&q=zzz"); + + expect(markup).toContain('data-loading="false"'); + expect(markup).toContain('data-total="0"'); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd web && pnpm vitest run src/pages/Catalog.test.tsx` +Expected: FAIL — the section is not rendered and the loading-state coordination is not implemented. + +- [ ] **Step 3: Wire the section into Catalog** + +In `web/src/pages/Catalog.tsx`, add imports: + +```typescript +import { RequestToAddSection } from "@/components/RequestToAddSection"; +import { useCanRequest } from "@/hooks/useCanRequest"; +import { useRequestSearch } from "@/hooks/queries/useRequests"; +``` + +Inside `CatalogResults`, after the existing `useCatalogWindow` call (around line 99-103), add: + +```typescript + const canRequest = useCanRequest(); + const isQuerySource = state.source === "query" && Boolean(state.q); + const tmdbQuery = useRequestSearch("all", state.q ?? "", 1, { + enabled: canRequest.discoveryEnabled && isQuerySource, + }); + const tmdbMissingCount = + tmdbQuery.data?.results?.filter((r) => r.availability !== "available").length ?? 0; + const libraryEmpty = (catalogQuery.data?.totalItems ?? 0) === 0; + const tmdbPendingForEmptyLibrary = + isQuerySource && canRequest.discoveryEnabled && libraryEmpty && tmdbQuery.isLoading; + const tmdbWillRenderForEmptyLibrary = + isQuerySource && canRequest.discoveryEnabled && libraryEmpty && tmdbMissingCount > 0; + const itemGridLoading = + catalogQuery.isLoading || tmdbPendingForEmptyLibrary || tmdbWillRenderForEmptyLibrary; +``` + +Update the `` prop: + +```typescript + +``` + +After the `ItemGrid`, before the `ConfirmDialog`, render the section: + +```typescript + {isQuerySource ? ( + 0} + /> + ) : null} + + /`. +5. In an admin context, toggle `RequestsEnabled` off via the admin UI. Re-open Cmd+K and confirm the section does NOT appear. +6. Slow the network (devtools throttling) and search again. Confirm library results appear immediately while the section is pending; the section appears once TMDB returns. + +- [ ] **Step 4: If any scenario fails, file the gap and stop here** + +Do not paper over UI regressions. Each failing scenario gets a short bug report (file path, expected, actual). The implementation plan ends with manual confirmation, not with a brittle "looks good". + +--- + +## Verification summary (run before opening MR) + +- `cd web && pnpm run lint` → PASS +- `cd web && pnpm run format:check` → PASS +- `cd web && pnpm test` → PASS +- `make verify-local-paths` → PASS +- Manual smoke per Task 12 → PASS + +--- + +## Deviations from the spec (recorded for the MR description) + +- **`submitDisabledReason` is always `null` in this implementation.** The spec defines this as a viewer-level signal fed by `EffectivePolicy.LimitMode` and quota state, but the frontend has no API surface today that exposes the viewer's effective policy as a single value. Per-row disabled state is driven by `result.request.requestable` and `result.request.reason`, which the backend already enriches per result. The `submitDisabledReason` field is retained in the `useCanRequest()` return type as a forward-compatible stub. Populating it would require a small backend addition to `/api/v1/requests/status` (out of scope here per the spec's "no backend changes" framing). From 4715b8940f89793daafb5b02e48c399f9c46ed75 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 18:02:58 -0400 Subject: [PATCH 04/18] docs(plans): address Codex adversarial review for search request section Fixes the high-severity finding that RequestToAddSection's internal useRequestSearch call was not gated on discoveryEnabled, allowing /api/v1/requests/search and TMDB lookups to fire for users without request access. The plan now (1) passes { enabled: discoveryEnabled } to the section's hook, (2) gates the parent mount in GlobalSearch and Catalog on canRequest.discoveryEnabled as defense in depth, and (3) adds tests asserting both the enabled forwarding and the no-mount behavior when discovery is disabled. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-25-search-request-section.md | 61 ++++++++++++++++++- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-05-25-search-request-section.md b/docs/superpowers/plans/2026-05-25-search-request-section.md index 29a72ed5..269666b9 100644 --- a/docs/superpowers/plans/2026-05-25-search-request-section.md +++ b/docs/superpowers/plans/2026-05-25-search-request-section.md @@ -790,6 +790,33 @@ describe("RequestToAddSection (dialog variant)", () => { expect(markup).toBe(""); }); + it("passes enabled=false to useRequestSearch when discovery is disabled so no network call fires", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false }); + + render(); + + const call = mocks.useRequestSearch.mock.calls.at(-1); + expect(call?.[0]).toBe("all"); + expect(call?.[1]).toBe("dune"); + expect(call?.[2]).toBe(1); + expect(call?.[3]).toEqual({ enabled: false }); + }); + + it("passes enabled=true to useRequestSearch when discovery is enabled", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 0, results: [] }, + isLoading: false, + isError: false, + }); + + render(); + + const call = mocks.useRequestSearch.mock.calls.at(-1); + expect(call?.[3]).toEqual({ enabled: true }); + }); + it("renders 'Request to Add' header when library had hits", () => { mocks.useRequestSearch.mockReturnValue({ data: { page: 1, total_pages: 1, total_results: 1, results: [missingResult()] }, @@ -920,7 +947,10 @@ export type RequestToAddSectionProps = { export function RequestToAddSection({ variant, query, libraryHadHits }: RequestToAddSectionProps) { const { discoveryEnabled } = useCanRequest(); - const search = useRequestSearch("all", query, 1); + // Gate the TMDB query firing on discovery eligibility. The `!discoveryEnabled` + // early return below hides the UI, but the hook still runs unconditionally + // (rules of hooks) — passing `enabled` is what prevents the network call. + const search = useRequestSearch("all", query, 1, { enabled: discoveryEnabled }); if (!discoveryEnabled) return null; if (search.isError) return null; @@ -1323,6 +1353,13 @@ describe("GlobalSearch + RequestToAddSection wiring", () => { expect(call?.[3]).toEqual({ enabled: false }); }); + it("does not mount RequestToAddSection when discovery is disabled", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" }); + + expect(markup).not.toContain('data-testid="request-section"'); + }); + it("suppresses 'No matches' when library is empty and TMDB is still loading", () => { mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); mocks.useQuery.mockReturnValue({ @@ -1464,7 +1501,7 @@ Then in the `showResultsPanel` JSX block, add the `` render onPick={handlePickItem} /> ))} - {tmdbDebouncedQuery.length > 1 && ( + {tmdbDebouncedQuery.length > 1 && canRequest.discoveryEnabled && ( { }); it("renders the grid variant when source=query and library has results", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); mocks.useCatalogWindow.mockReturnValue({ data: { title: 'Results for "dune"', @@ -1608,6 +1646,7 @@ describe("Catalog + RequestToAddSection wiring", () => { }); it("renders the grid variant with libraryHadHits=false when library has 0 hits", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); mocks.useCatalogWindow.mockReturnValue({ data: { title: 'Results for "noresults"', totalItems: 0, pages: new Map() }, isLoading: false, @@ -1619,6 +1658,7 @@ describe("Catalog + RequestToAddSection wiring", () => { }); it("does not render the section when source is not query", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); mocks.useCatalogWindow.mockReturnValue({ data: { title: "Favorites", totalItems: 0, pages: new Map() }, isLoading: false, @@ -1628,6 +1668,21 @@ describe("Catalog + RequestToAddSection wiring", () => { expect(markup).not.toContain('data-testid="request-section"'); }); + it("does not render the section when discovery is disabled", () => { + // Default beforeEach sets discoveryEnabled=false; assert the parent gate blocks the mount. + mocks.useCatalogWindow.mockReturnValue({ + data: { + title: 'Results for "dune"', + totalItems: 2, + pages: new Map([[0, [{ content_id: "lib-1", title: "Dune", type: "movie", year: 2021 }]]]), + }, + isLoading: false, + }); + + const markup = render("/catalog?source=query&q=dune"); + expect(markup).not.toContain('data-testid="request-section"'); + }); + it("passes enabled=false to useRequestSearch when discoveryEnabled is false", () => { mocks.useCatalogWindow.mockReturnValue({ data: { title: 'Results for "dune"', totalItems: 0, pages: new Map() }, @@ -1749,7 +1804,7 @@ Update the `` prop: After the `ItemGrid`, before the `ConfirmDialog`, render the section: ```typescript - {isQuerySource ? ( + {isQuerySource && canRequest.discoveryEnabled ? ( Date: Mon, 25 May 2026 18:10:06 -0400 Subject: [PATCH 05/18] test(api): pin AbortSignal forwarding contract on api() --- web/src/api/client.test.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 8906e50d..a663d158 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -121,6 +121,34 @@ describe("client helper inventory", () => { }); describe("api", () => { + it("forwards AbortSignal from options to fetch", async () => { + Object.defineProperty(globalThis, "sessionStorage", { + value: { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + }, + configurable: true, + }); + + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const controller = new AbortController(); + await api("/test", { signal: controller.signal }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const call = fetchMock.mock.calls[0]!; + const init = call[1] as RequestInit; + expect(init.signal).toBe(controller.signal); + }); + it("treats 202 responses with an empty body as success", async () => { Object.defineProperty(globalThis, "sessionStorage", { value: { From d923051513f61bba7888c357824e3bf0117f83bc Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 18:10:58 -0400 Subject: [PATCH 06/18] feat(hooks): add useCanRequest gating hook for discovery eligibility --- web/src/hooks/useCanRequest.test.tsx | 96 ++++++++++++++++++++++++++++ web/src/hooks/useCanRequest.ts | 18 ++++++ 2 files changed, 114 insertions(+) create mode 100644 web/src/hooks/useCanRequest.test.tsx create mode 100644 web/src/hooks/useCanRequest.ts diff --git a/web/src/hooks/useCanRequest.test.tsx b/web/src/hooks/useCanRequest.test.tsx new file mode 100644 index 00000000..75f15344 --- /dev/null +++ b/web/src/hooks/useCanRequest.test.tsx @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mocks = vi.hoisted(() => ({ + useRequestFeatureStatus: vi.fn(), + useCurrentProfile: vi.fn(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestFeatureStatus: () => mocks.useRequestFeatureStatus(), +})); + +vi.mock("@/hooks/useCurrentProfile", () => ({ + useCurrentProfile: () => mocks.useCurrentProfile(), +})); + +import { useCanRequest } from "./useCanRequest"; + +function CaptureHook({ onResult }: { onResult: (r: ReturnType) => void }) { + const result = useCanRequest(); + onResult(result); + return null; +} + +function render(child: ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup({child}); +} + +describe("useCanRequest", () => { + it("returns discoveryEnabled=false when requests_enabled is false", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: { requests_enabled: false } }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } }); + + let captured: ReturnType | null = null; + render( + { + captured = r; + }} + />, + ); + + expect(captured).toEqual({ discoveryEnabled: false, submitDisabledReason: null }); + }); + + it("returns discoveryEnabled=false when there is no profile", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: { requests_enabled: true } }); + mocks.useCurrentProfile.mockReturnValue({ profile: null }); + + let captured: ReturnType | null = null; + render( + { + captured = r; + }} + />, + ); + + expect(captured).toEqual({ discoveryEnabled: false, submitDisabledReason: null }); + }); + + it("returns discoveryEnabled=true when requests are enabled and there is a profile", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: { requests_enabled: true } }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } }); + + let captured: ReturnType | null = null; + render( + { + captured = r; + }} + />, + ); + + expect(captured).toEqual({ discoveryEnabled: true, submitDisabledReason: null }); + }); + + it("returns discoveryEnabled=false while the feature status is still loading", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: undefined }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } }); + + let captured: ReturnType | null = null; + render( + { + captured = r; + }} + />, + ); + + expect(captured).toEqual({ discoveryEnabled: false, submitDisabledReason: null }); + }); +}); diff --git a/web/src/hooks/useCanRequest.ts b/web/src/hooks/useCanRequest.ts new file mode 100644 index 00000000..f702c98b --- /dev/null +++ b/web/src/hooks/useCanRequest.ts @@ -0,0 +1,18 @@ +import { useRequestFeatureStatus } from "@/hooks/queries/useRequests"; +import { useCurrentProfile } from "@/hooks/useCurrentProfile"; + +export interface CanRequestState { + discoveryEnabled: boolean; + submitDisabledReason: string | null; +} + +export function useCanRequest(): CanRequestState { + const status = useRequestFeatureStatus(); + const { profile } = useCurrentProfile(); + const discoveryEnabled = Boolean(status.data?.requests_enabled) && Boolean(profile?.id); + + return { + discoveryEnabled, + submitDisabledReason: null, + }; +} From 70bb7a3e2b1b2fca47e790d96f67f990d58f5d2b Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 18:11:11 -0400 Subject: [PATCH 07/18] refactor(keys): add viewerKey to requestKeys.search --- web/src/hooks/queries/keys.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index 4e98afd1..cf139be3 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -132,8 +132,8 @@ export const requestKeys = { sort: string, page: number, ) => ["requests", "discover", "browse", kind, slug, mediaType ?? "", sort, page] as const, - search: (mediaType: string, query: string, page: number) => - ["requests", "search", mediaType, query, page] as const, + search: (mediaType: string, query: string, page: number, viewerKey: string) => + ["requests", "search", viewerKey, mediaType, query, page] as const, detail: (mediaType: string, tmdbID: number) => ["requests", "detail", mediaType, tmdbID] as const, mine: (params: Record) => ["requests", "mine", params] as const, }; From a2a34953613a01a0a8922fde64314781a749ca9e Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 18:12:19 -0400 Subject: [PATCH 08/18] feat(requests): key useRequestSearch by viewer, forward signal, raise staleTime --- web/src/hooks/queries/useRequests.test.tsx | 110 +++++++++++++++++++++ web/src/hooks/queries/useRequests.ts | 28 ++++-- 2 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 web/src/hooks/queries/useRequests.test.tsx diff --git a/web/src/hooks/queries/useRequests.test.tsx b/web/src/hooks/queries/useRequests.test.tsx new file mode 100644 index 00000000..5767e33a --- /dev/null +++ b/web/src/hooks/queries/useRequests.test.tsx @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mocks = vi.hoisted(() => ({ + useQuery: vi.fn(), + useCurrentProfile: vi.fn(), + api: vi.fn(), +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = + await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useQuery: (...args: unknown[]) => mocks.useQuery(...args), + }; +}); + +vi.mock("@/hooks/useCurrentProfile", () => ({ + useCurrentProfile: () => mocks.useCurrentProfile(), +})); + +vi.mock("@/api/client", () => ({ + api: (...args: unknown[]) => mocks.api(...args), +})); + +import { useRequestSearch } from "./useRequests"; + +function render(node: ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup({node}); +} + +function CallHook(props: { mediaType: "movie" | "series" | "all"; q: string; page?: number }) { + useRequestSearch(props.mediaType, props.q, props.page ?? 1); + return null; +} + +describe("useRequestSearch", () => { + beforeEach(() => { + mocks.useQuery.mockReset(); + mocks.useCurrentProfile.mockReset(); + mocks.api.mockReset(); + }); + + it("includes the current profile id in the query key", () => { + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "profile-1" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { queryKey: readonly unknown[] }; + expect(options.queryKey).toEqual(["requests", "search", "profile-1", "all", "dune", 1]); + }); + + it("uses 'anon' as the viewer key when there is no profile", () => { + mocks.useCurrentProfile.mockReturnValue({ profile: null }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { queryKey: readonly unknown[] }; + expect(options.queryKey).toEqual(["requests", "search", "anon", "movie", "dune", 1]); + }); + + it("forwards the react-query signal to api()", async () => { + mocks.api.mockResolvedValue({ page: 1, total_pages: 0, total_results: 0, results: [] }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "profile-1" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { + queryFn: (ctx: { signal: AbortSignal }) => unknown; + }; + const controller = new AbortController(); + await options.queryFn({ signal: controller.signal }); + + expect(mocks.api).toHaveBeenCalledTimes(1); + const apiCall = mocks.api.mock.calls[0]!; + expect(apiCall[0]).toContain("/requests/search?"); + const init = apiCall[1] as RequestInit; + expect(init.signal).toBe(controller.signal); + }); + + it("uses a 5-minute staleTime", () => { + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { staleTime: number }; + expect(options.staleTime).toBe(5 * 60 * 1000); + }); + + it("respects the enabled option override", () => { + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } }); + + function CallHookWithOpt({ enabled }: { enabled: boolean }) { + useRequestSearch("all", "dune", 1, { enabled }); + return null; + } + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean }; + expect(options.enabled).toBe(false); + }); + + it("does not include enabled override when option omitted (defaults to true)", () => { + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean }; + expect(options.enabled).toBe(true); + }); +}); diff --git a/web/src/hooks/queries/useRequests.ts b/web/src/hooks/queries/useRequests.ts index 1566ee08..4ba1bab9 100644 --- a/web/src/hooks/queries/useRequests.ts +++ b/web/src/hooks/queries/useRequests.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { api } from "@/api/client"; +import { useCurrentProfile } from "@/hooks/useCurrentProfile"; import type { CreateMediaRequestInput, DiscoverBrowseKind, @@ -28,6 +29,7 @@ import type { import { adminKeys, requestKeys } from "./keys"; const REQUESTS_STALE_TIME = 30_000; +const REQUEST_SEARCH_STALE_TIME = 5 * 60 * 1000; const DISCOVER_BRAND_STALE_TIME = 24 * 60 * 60 * 1000; const BROWSE_STALE_TIME = 60 * 1000; @@ -148,20 +150,34 @@ export function useRequestMediaDetail(mediaType: RequestMediaType, tmdbID: numbe }); } -export function useRequestSearch(mediaType: RequestSearchMediaType, query: string, page = 1) { +export interface UseRequestSearchOptions { + /** When false, suppresses the query regardless of the query string. Default: true. */ + enabled?: boolean; +} + +export function useRequestSearch( + mediaType: RequestSearchMediaType, + query: string, + page = 1, + options: UseRequestSearchOptions = {}, +) { const normalizedQuery = query.trim(); + const { profile } = useCurrentProfile(); + const viewerKey = profile?.id ?? "anon"; + const enabledOverride = options.enabled ?? true; + return useQuery({ - queryKey: requestKeys.search(mediaType, normalizedQuery, page), - queryFn: () => { + queryKey: requestKeys.search(mediaType, normalizedQuery, page, viewerKey), + queryFn: ({ signal }) => { const params = new URLSearchParams({ q: normalizedQuery, media_type: mediaType, page: String(page), }); - return api(`/requests/search?${params}`); + return api(`/requests/search?${params}`, { signal }); }, - enabled: normalizedQuery.length > 1, - staleTime: REQUESTS_STALE_TIME, + enabled: enabledOverride && normalizedQuery.length > 1, + staleTime: REQUEST_SEARCH_STALE_TIME, }); } From c2ea1966f5ee11d757e525a30c6f53146935e187 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 18:12:45 -0400 Subject: [PATCH 09/18] test(requests): document viewer-keyed cache isolation and invalidation cascade --- web/src/hooks/queries/useRequests.test.tsx | 28 ++++++++++++++++++++++ web/src/hooks/queries/useRequests.ts | 3 +++ 2 files changed, 31 insertions(+) diff --git a/web/src/hooks/queries/useRequests.test.tsx b/web/src/hooks/queries/useRequests.test.tsx index 5767e33a..a59213b6 100644 --- a/web/src/hooks/queries/useRequests.test.tsx +++ b/web/src/hooks/queries/useRequests.test.tsx @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import type { ReactNode } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { requestKeys } from "./keys"; const mocks = vi.hoisted(() => ({ useQuery: vi.fn(), @@ -108,3 +109,30 @@ describe("useRequestSearch", () => { expect(options.enabled).toBe(true); }); }); + +describe("requestKeys.all invalidation", () => { + it("invalidates entries under requestKeys.search() when invalidating requestKeys.all", async () => { + const client = new QueryClient(); + client.setQueryData(requestKeys.search("all", "dune", 1, "profile-1"), { sentinel: true }); + + expect(client.getQueryData(requestKeys.search("all", "dune", 1, "profile-1"))).toEqual({ + sentinel: true, + }); + + await client.invalidateQueries({ queryKey: requestKeys.all }); + + const state = client.getQueryState(requestKeys.search("all", "dune", 1, "profile-1")); + expect(state?.isInvalidated).toBe(true); + }); +}); + +describe("viewer-scoped cache isolation", () => { + it("does not return profile-1 results when keyed by profile-2", () => { + const client = new QueryClient(); + client.setQueryData(requestKeys.search("all", "dune", 1, "profile-1"), { + results: [{ tmdb_id: 1 }], + }); + + expect(client.getQueryData(requestKeys.search("all", "dune", 1, "profile-2"))).toBeUndefined(); + }); +}); diff --git a/web/src/hooks/queries/useRequests.ts b/web/src/hooks/queries/useRequests.ts index 4ba1bab9..a7b9ea14 100644 --- a/web/src/hooks/queries/useRequests.ts +++ b/web/src/hooks/queries/useRequests.ts @@ -53,6 +53,9 @@ function buildListQuery(params: RequestListParams = {}) { } function invalidateRequestSurfaces(queryClient: ReturnType) { + // requestKeys.all = ["requests"], so invalidating it cascades to nested keys, + // including requestKeys.search(...). Policy mutations rely on this to refresh + // viewer-scoped search results when request eligibility changes. queryClient.invalidateQueries({ queryKey: requestKeys.all }); queryClient.invalidateQueries({ queryKey: adminKeys.requestsRoot() }); } From 09148cac4659c51dd26a4db5a8c8affb39852285 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 18:13:31 -0400 Subject: [PATCH 10/18] feat(request-card): make onRequest optional on discover variant --- web/src/components/RequestPosterCard.test.tsx | 39 +++++++++++++++++++ web/src/components/RequestPosterCard.tsx | 14 ++++--- 2 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 web/src/components/RequestPosterCard.test.tsx diff --git a/web/src/components/RequestPosterCard.test.tsx b/web/src/components/RequestPosterCard.test.tsx new file mode 100644 index 00000000..215a5ccf --- /dev/null +++ b/web/src/components/RequestPosterCard.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; +import RequestPosterCard from "./RequestPosterCard"; +import type { RequestMediaResult } from "@/api/types"; + +const requestable: RequestMediaResult = { + media_type: "movie", + tmdb_id: 42, + title: "Test Movie", + availability: "missing", + request: { requestable: true }, +}; + +describe("RequestPosterCard (discover variant)", () => { + it("renders the hover Request button when onRequest is provided", () => { + const markup = renderToStaticMarkup( + + {}} + /> + , + ); + expect(markup).toContain("Request"); + }); + + it("does not render the hover Request button when onRequest is omitted", () => { + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).not.toContain("rounded-full bg-white"); + }); +}); diff --git a/web/src/components/RequestPosterCard.tsx b/web/src/components/RequestPosterCard.tsx index f2599c97..08cd6cc6 100644 --- a/web/src/components/RequestPosterCard.tsx +++ b/web/src/components/RequestPosterCard.tsx @@ -9,8 +9,10 @@ const POSTER_WIDTH = "w-[148px] sm:w-[164px] lg:w-[184px]"; type DiscoverProps = { variant: "discover"; item: RequestMediaResult; - isSubmitting: boolean; - onRequest: () => void; + /** Called when the inline hover Request button is clicked. Omit to suppress the button. */ + onRequest?: () => void; + /** Displays the spinner state on the hover Request button. Ignored when onRequest is omitted. */ + isSubmitting?: boolean; /** When true, fills the parent (use inside grids). Default: fixed carousel width. */ fluid?: boolean; }; @@ -44,8 +46,8 @@ function DiscoverCard({ fluid, }: { item: RequestMediaResult; - isSubmitting: boolean; - onRequest: () => void; + isSubmitting?: boolean; + onRequest?: () => void; fluid?: boolean; }) { const poster = tmdbImageURL(item.poster_path); @@ -92,11 +94,11 @@ function DiscoverCard({ /> - {requestable && ( + {requestable && onRequest && (
{items.length} results found From a64f41da4566b8e548657f2a468742b24d189271 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 18:19:42 -0400 Subject: [PATCH 14/18] feat(catalog): render RequestToAddSection grid with empty-state suppression --- web/src/pages/Catalog.test.tsx | 222 ++++++++++++++++++++++++++++++++- web/src/pages/Catalog.tsx | 27 +++- 2 files changed, 245 insertions(+), 4 deletions(-) diff --git a/web/src/pages/Catalog.test.tsx b/web/src/pages/Catalog.test.tsx index 16d69715..e176cdd2 100644 --- a/web/src/pages/Catalog.test.tsx +++ b/web/src/pages/Catalog.test.tsx @@ -10,6 +10,8 @@ let latestNavigateTo: string | null = null; const mockUseCatalogWindow = vi.fn(); const mockUseCatalogFilters = vi.fn(); const mockItemGrid = vi.fn(); +const mockUseCanRequest = vi.fn(); +const mockUseRequestSearch = vi.fn(); vi.mock("react-router", async () => { const actual = await vi.importActual("react-router"); @@ -38,6 +40,30 @@ vi.mock("@/hooks/queries/catalog", () => ({ useCatalogMetadataFilters: (...args: unknown[]) => mockUseCatalogFilters(...args), })); +vi.mock("@/hooks/useCanRequest", () => ({ + useCanRequest: () => mockUseCanRequest(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestSearch: (...args: unknown[]) => mockUseRequestSearch(...args), +})); + +vi.mock("@/components/RequestToAddSection", () => ({ + RequestToAddSection: ({ + variant, + query, + libraryHadHits, + }: { + variant: string; + query: string; + libraryHadHits: boolean; + }) => ( +
+ {`variant="${variant}" query="${query}" libraryHadHits="${String(libraryHadHits)}"`} +
+ ), +})); + vi.mock("@/hooks/useAuth", () => ({ AuthProvider: ({ children }: { children: ReactNode }) => <>{children}, useAuth: () => ({ @@ -89,10 +115,19 @@ vi.mock("@/components/ItemGrid", () => ({ items?: Array<{ title: string }>; totalItems?: number; pageSize?: number; + loading?: boolean; onVisibleRangeChange?: (start: number, end: number) => void; }) => { mockItemGrid(props); - return
{props.items?.map((item) => item.title).join(",")}
; + return ( +
+ {props.items?.map((item) => item.title).join(",")} +
+ ); }, })); @@ -152,6 +187,10 @@ describe("Catalog page", () => { mockUseCatalogWindow.mockReset(); mockUseCatalogFilters.mockReset(); mockItemGrid.mockReset(); + mockUseCanRequest.mockReset(); + mockUseRequestSearch.mockReset(); + mockUseCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mockUseRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false }); mockUseCatalogWindow.mockReturnValue({ data: { @@ -273,7 +312,7 @@ describe("Catalog page", () => { expect(markup).toContain("Settings"); }); - it("redirects the retired user plugins settings route back to appearance settings", () => { + it("redirects the retired user plugins settings route back to playback settings", () => { appInitialEntries = ["/settings/plugins"]; renderToStaticMarkup( @@ -282,6 +321,183 @@ describe("Catalog page", () => { , ); - expect(latestNavigateTo).toBe("appearance"); + expect(latestNavigateTo).toBe("/settings/playback"); + }); + + it("renders the request grid variant when source=query and library has results", () => { + mockUseCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mockUseRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { + media_type: "movie", + tmdb_id: 1, + title: "X", + availability: "missing", + request: { requestable: true }, + }, + ], + }, + isLoading: false, + isError: false, + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain('data-testid="request-section"'); + expect(markup).toContain("variant="grid""); + expect(markup).toContain("libraryHadHits="true""); + }); + + it("renders the request grid variant with libraryHadHits=false when library has 0 hits", () => { + mockUseCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mockUseCatalogWindow.mockReturnValue({ + data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mockUseRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { + media_type: "movie", + tmdb_id: 1, + title: "X", + availability: "missing", + request: { requestable: true }, + }, + ], + }, + isLoading: false, + isError: false, + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain("libraryHadHits="false""); + }); + + it("does not render the request section when source is not query", () => { + appInitialEntries = ["/catalog?source=favorites"]; + mockUseCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mockUseCatalogWindow.mockReturnValue({ + data: { title: "Favorites", totalItems: 0, pages: new Map() }, + isLoading: false, + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).not.toContain('data-testid="request-section"'); + }); + + it("does not render the request section when discovery is disabled", () => { + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).not.toContain('data-testid="request-section"'); + }); + + it("passes enabled=false to useRequestSearch when discoveryEnabled is false", () => { + renderToStaticMarkup( + + + , + ); + + const call = mockUseRequestSearch.mock.calls.at(-1); + expect(call?.[3]).toEqual({ enabled: false }); + }); + + it("keeps ItemGrid in a loading state when library is empty and TMDB is still loading", () => { + mockUseCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mockUseCatalogWindow.mockReturnValue({ + data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mockUseRequestSearch.mockReturnValue({ data: undefined, isLoading: true, isError: false }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain('data-loading="true"'); + }); + + it("keeps ItemGrid in a loading state when library is empty and TMDB has missing results", () => { + mockUseCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mockUseCatalogWindow.mockReturnValue({ + data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mockUseRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { + media_type: "movie", + tmdb_id: 1, + title: "X", + availability: "missing", + request: { requestable: true }, + }, + ], + }, + isLoading: false, + isError: false, + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain('data-loading="true"'); + }); + + it("renders the normal ItemGrid empty state when both library and TMDB are empty", () => { + mockUseCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mockUseCatalogWindow.mockReturnValue({ + data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mockUseRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 0, results: [] }, + isLoading: false, + isError: false, + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain('data-loading="false"'); + expect(markup).toContain('data-total="0"'); }); }); diff --git a/web/src/pages/Catalog.tsx b/web/src/pages/Catalog.tsx index ec28a0cf..5d6f767e 100644 --- a/web/src/pages/Catalog.tsx +++ b/web/src/pages/Catalog.tsx @@ -4,10 +4,13 @@ import { CheckSquare, Search, Trash2, X } from "lucide-react"; import type { BrowseItem } from "@/api/types"; import ItemGrid from "@/components/ItemGrid"; +import { RequestToAddSection } from "@/components/RequestToAddSection"; import { Button } from "@/components/ui/button"; import CatalogFiltersPanel from "@/components/catalog/CatalogFiltersPanel"; import { useCatalogWindow } from "@/hooks/queries/catalog"; import { useRemoveHistory } from "@/hooks/queries/history"; +import { useRequestSearch } from "@/hooks/queries/useRequests"; +import { useCanRequest } from "@/hooks/useCanRequest"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; import SearchBar from "@/components/SearchBar"; import { ConfirmDialog } from "@/components/ConfirmDialog"; @@ -101,6 +104,20 @@ function CatalogResults({ visibleRange, includeTotal: showExactResultCount, }); + const canRequest = useCanRequest(); + const isQuerySource = state.source === "query" && Boolean(state.q); + const tmdbQuery = useRequestSearch("all", state.q ?? "", 1, { + enabled: canRequest.discoveryEnabled && isQuerySource, + }); + const tmdbMissingCount = + tmdbQuery.data?.results?.filter((result) => result.availability !== "available").length ?? 0; + const libraryEmpty = (catalogQuery.data?.totalItems ?? 0) === 0; + const tmdbPendingForEmptyLibrary = + isQuerySource && canRequest.discoveryEnabled && libraryEmpty && tmdbQuery.isLoading; + const tmdbWillRenderForEmptyLibrary = + isQuerySource && canRequest.discoveryEnabled && libraryEmpty && tmdbMissingCount > 0; + const itemGridLoading = + catalogQuery.isLoading || tmdbPendingForEmptyLibrary || tmdbWillRenderForEmptyLibrary; const loadedHistoryItems = useMemo(() => { if (!isHistorySource) { return [] as BrowseItem[]; @@ -253,13 +270,21 @@ function CatalogResults({ totalItems={catalogQuery.data?.totalItems ?? 0} pages={catalogQuery.data?.pages ?? new Map()} pageSize={limit} - loading={catalogQuery.isLoading} + loading={itemGridLoading} onVisibleRangeChange={handleVisibleRangeChange} selectionMode={isHistorySource && selectionMode} selectedIds={selectedIds} onToggleSelect={toggleHistorySelection} /> + {isQuerySource && canRequest.discoveryEnabled ? ( + 0} + /> + ) : null} + Date: Mon, 25 May 2026 18:20:39 -0400 Subject: [PATCH 15/18] chore(web): format request search section --- web/src/components/RequestToAddSection.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/web/src/components/RequestToAddSection.tsx b/web/src/components/RequestToAddSection.tsx index 6e7492e2..f1954e55 100644 --- a/web/src/components/RequestToAddSection.tsx +++ b/web/src/components/RequestToAddSection.tsx @@ -24,9 +24,7 @@ export function RequestToAddSection({ variant, query, libraryHadHits }: RequestT if (!discoveryEnabled) return null; if (search.isError) return null; - const filtered = (search.data?.results ?? []).filter( - (item) => item.availability !== "available", - ); + const filtered = (search.data?.results ?? []).filter((item) => item.availability !== "available"); if (filtered.length === 0) return null; const limit = variant === "dialog" ? DIALOG_LIMIT : GRID_LIMIT; From e1bf9f56660867e55388a47c575f5af445497bc9 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 18:22:27 -0400 Subject: [PATCH 16/18] test(web): avoid unsupported Array.at in search request tests --- web/src/components/GlobalSearch.test.tsx | 2 +- web/src/components/RequestToAddSection.test.tsx | 4 ++-- web/src/pages/Catalog.test.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/components/GlobalSearch.test.tsx b/web/src/components/GlobalSearch.test.tsx index dd3c2083..9758a0a2 100644 --- a/web/src/components/GlobalSearch.test.tsx +++ b/web/src/components/GlobalSearch.test.tsx @@ -219,7 +219,7 @@ describe("GlobalSearch + RequestToAddSection wiring", () => { mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" }); - const call = mocks.useRequestSearch.mock.calls.at(-1); + const call = mocks.useRequestSearch.mock.calls[mocks.useRequestSearch.mock.calls.length - 1]; expect(call?.[3]).toEqual({ enabled: false }); }); diff --git a/web/src/components/RequestToAddSection.test.tsx b/web/src/components/RequestToAddSection.test.tsx index 22002e36..2e0053f1 100644 --- a/web/src/components/RequestToAddSection.test.tsx +++ b/web/src/components/RequestToAddSection.test.tsx @@ -77,7 +77,7 @@ describe("RequestToAddSection (dialog variant)", () => { render(); - const call = mocks.useRequestSearch.mock.calls.at(-1); + const call = mocks.useRequestSearch.mock.calls[mocks.useRequestSearch.mock.calls.length - 1]; expect(call?.[0]).toBe("all"); expect(call?.[1]).toBe("dune"); expect(call?.[2]).toBe(1); @@ -94,7 +94,7 @@ describe("RequestToAddSection (dialog variant)", () => { render(); - const call = mocks.useRequestSearch.mock.calls.at(-1); + const call = mocks.useRequestSearch.mock.calls[mocks.useRequestSearch.mock.calls.length - 1]; expect(call?.[3]).toEqual({ enabled: true }); }); diff --git a/web/src/pages/Catalog.test.tsx b/web/src/pages/Catalog.test.tsx index e176cdd2..72a24cc0 100644 --- a/web/src/pages/Catalog.test.tsx +++ b/web/src/pages/Catalog.test.tsx @@ -424,7 +424,7 @@ describe("Catalog page", () => { , ); - const call = mockUseRequestSearch.mock.calls.at(-1); + const call = mockUseRequestSearch.mock.calls[mockUseRequestSearch.mock.calls.length - 1]; expect(call?.[3]).toEqual({ enabled: false }); }); From 17a2cb0844c1a6b5b4e1aad5c5a591e5166c2c3a Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 22:27:46 -0400 Subject: [PATCH 17/18] commit message {"subject":"fix(search): prevent empty-state flash before TMDB fallback renders","body":"- Add isResolving to useCanRequest and gate empty states on it across GlobalSearch and Catalog\n- Debounce TMDB query in Catalog and hide ItemGrid when the request section may rescue an empty library\n- Track per-card submit state in RequestToAddSection grid so concurrent requests don't trample each other\n- Suppress anonymous TMDB request-search fetches to avoid cross-viewer cache leakage"} --- web/src/components/GlobalSearch.test.tsx | 54 ++++++++-- web/src/components/GlobalSearch.tsx | 69 +++++++------ web/src/components/RequestPosterCard.test.tsx | 8 +- .../components/RequestToAddSection.test.tsx | 53 ++++++++-- web/src/components/RequestToAddSection.tsx | 99 +++++++++++++++---- web/src/hooks/queries/useRequests.ts | 6 +- web/src/hooks/useCanRequest.test.tsx | 43 ++++++-- web/src/hooks/useCanRequest.ts | 8 ++ web/src/pages/Catalog.test.tsx | 75 +++++++++++--- web/src/pages/Catalog.tsx | 49 +++++---- 10 files changed, 354 insertions(+), 110 deletions(-) diff --git a/web/src/components/GlobalSearch.test.tsx b/web/src/components/GlobalSearch.test.tsx index 9758a0a2..ca67ae5d 100644 --- a/web/src/components/GlobalSearch.test.tsx +++ b/web/src/components/GlobalSearch.test.tsx @@ -94,7 +94,11 @@ describe("GlobalSearch", () => { mocks.useQuery.mockReset(); mocks.useCanRequest.mockReset(); mocks.useRequestSearch.mockReset(); - mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useCanRequest.mockReturnValue({ + discoveryEnabled: false, + isResolving: false, + submitDisabledReason: null, + }); mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, @@ -145,7 +149,11 @@ describe("GlobalSearch + RequestToAddSection wiring", () => { mocks.useQuery.mockReset(); mocks.useCanRequest.mockReset(); mocks.useRequestSearch.mockReset(); - mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useCanRequest.mockReturnValue({ + discoveryEnabled: false, + isResolving: false, + submitDisabledReason: null, + }); mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, @@ -159,7 +167,11 @@ describe("GlobalSearch + RequestToAddSection wiring", () => { }); it("renders the section with libraryHadHits=true when library returned results", () => { - mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCanRequest.mockReturnValue({ + discoveryEnabled: true, + isResolving: false, + submitDisabledReason: null, + }); mocks.useRequestSearch.mockReturnValue({ data: { page: 1, @@ -186,7 +198,11 @@ describe("GlobalSearch + RequestToAddSection wiring", () => { }); it("renders the section with libraryHadHits=false when library returned 0 results", () => { - mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCanRequest.mockReturnValue({ + discoveryEnabled: true, + isResolving: false, + submitDisabledReason: null, + }); mocks.useQuery.mockReturnValue({ data: { total: 0, has_more: false, items: [] }, isFetching: false, @@ -216,7 +232,11 @@ describe("GlobalSearch + RequestToAddSection wiring", () => { }); it("does not call useRequestSearch with enabled=true when discoveryEnabled is false", () => { - mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useCanRequest.mockReturnValue({ + discoveryEnabled: false, + isResolving: false, + submitDisabledReason: null, + }); renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" }); const call = mocks.useRequestSearch.mock.calls[mocks.useRequestSearch.mock.calls.length - 1]; @@ -224,14 +244,22 @@ describe("GlobalSearch + RequestToAddSection wiring", () => { }); it("does not mount RequestToAddSection when discovery is disabled", () => { - mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useCanRequest.mockReturnValue({ + discoveryEnabled: false, + isResolving: false, + submitDisabledReason: null, + }); const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" }); expect(markup).not.toContain('data-testid="request-section"'); }); it("suppresses 'No matches' when library is empty and TMDB is still loading", () => { - mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCanRequest.mockReturnValue({ + discoveryEnabled: true, + isResolving: false, + submitDisabledReason: null, + }); mocks.useQuery.mockReturnValue({ data: { total: 0, has_more: false, items: [] }, isFetching: false, @@ -248,7 +276,11 @@ describe("GlobalSearch + RequestToAddSection wiring", () => { }); it("suppresses 'No matches' when library is empty and TMDB has missing results", () => { - mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCanRequest.mockReturnValue({ + discoveryEnabled: true, + isResolving: false, + submitDisabledReason: null, + }); mocks.useQuery.mockReturnValue({ data: { total: 0, has_more: false, items: [] }, isFetching: false, @@ -278,7 +310,11 @@ describe("GlobalSearch + RequestToAddSection wiring", () => { }); it("still shows 'No matches' when both library and TMDB are empty", () => { - mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCanRequest.mockReturnValue({ + discoveryEnabled: true, + isResolving: false, + submitDisabledReason: null, + }); mocks.useQuery.mockReturnValue({ data: { total: 0, has_more: false, items: [] }, isFetching: false, diff --git a/web/src/components/GlobalSearch.tsx b/web/src/components/GlobalSearch.tsx index 9d910a55..5eaf3f80 100644 --- a/web/src/components/GlobalSearch.tsx +++ b/web/src/components/GlobalSearch.tsx @@ -111,9 +111,15 @@ export function GlobalSearch({ }); const tmdbMissingCount = tmdbQuery.data?.results?.filter((result) => result.availability !== "available").length ?? 0; + // Cap at DIALOG_LIMIT (4) — RequestToAddSection slices results to that many rows. + const tmdbVisibleCount = Math.min(tmdbMissingCount, 4); const tmdbStillLoading = canRequest.discoveryEnabled && tmdbDebouncedQuery.length > 1 && tmdbQuery.isLoading; const tmdbWillRender = canRequest.discoveryEnabled && tmdbMissingCount > 0; + // Hide empty state while the TMDB debounce trails the library debounce; otherwise + // the user sees "No matches" flash between t=200ms and t=400ms after typing. + const tmdbDebounceCatchingUp = + canRequest.discoveryEnabled && tmdbDebouncedQuery !== debouncedQuery; const searchState = useMemo( () => createCatalogSearchState("query", { q: debouncedQuery || undefined }), @@ -195,7 +201,9 @@ export function GlobalSearch({ items.length === 0 && !previewQuery.isError && !tmdbStillLoading && - !tmdbWillRender; + !tmdbWillRender && + !canRequest.isResolving && + !tmdbDebounceCatchingUp; const showError = previewQuery.isError; return ( @@ -252,34 +260,33 @@ export function GlobalSearch({ {showResultsPanel && (
-
- {showLoading && ( -
- Searching... -
- )} - {showError && ( -
- Could not load results. Press Enter to open the search page. -
- )} - {showEmpty && ( -
- No matches -
- )} - {items.map((item, i) => ( - - ))} +
+
+ {showLoading && ( +
+ Searching... +
+ )} + {showError && ( +
+ Could not load results. Press Enter to open the search page. +
+ )} + {showEmpty && ( +
+ No matches +
+ )} + {items.map((item, i) => ( + + ))} +
{tmdbDebouncedQuery.length > 1 && canRequest.discoveryEnabled && (
- {items.length} results found + {tmdbVisibleCount > 0 + ? `${items.length} library results, ${tmdbVisibleCount} request suggestions` + : `${items.length} results found`}
{total > PREVIEW_LIMIT ? ( diff --git a/web/src/components/RequestPosterCard.test.tsx b/web/src/components/RequestPosterCard.test.tsx index 215a5ccf..904c9bcd 100644 --- a/web/src/components/RequestPosterCard.test.tsx +++ b/web/src/components/RequestPosterCard.test.tsx @@ -24,7 +24,9 @@ describe("RequestPosterCard (discover variant)", () => { /> , ); - expect(markup).toContain("Request"); + // Must render an actual