Merge pull request #9 from Silo-Server/t3code/search-tmdb-fallback-results
feat(search): add TMDB-backed Request to Add section to search surfaces
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
# 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
|
||||
|
||||
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. 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). 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 — 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.
|
||||
|
||||
### 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 `{ 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`.
|
||||
|
||||
## 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 (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
|
||||
|
||||
- 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, 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.
|
||||
@@ -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: {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useQuery: vi.fn(),
|
||||
useCanRequest: vi.fn(),
|
||||
useRequestSearch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
@@ -21,6 +23,30 @@ vi.mock("@/hooks/useDebounce", () => ({
|
||||
useDebounce: <T,>(v: T) => v,
|
||||
}));
|
||||
|
||||
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;
|
||||
}) => (
|
||||
<div data-testid="request-section">
|
||||
{`variant="${variant}" query="${query}" libraryHadHits="${String(libraryHadHits)}"`}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ children, open }: { children: ReactNode; open: boolean }) =>
|
||||
open ? <div data-testid="dialog">{children}</div> : null,
|
||||
@@ -66,6 +92,18 @@ function renderSearchMarkup(props: Partial<Parameters<typeof GlobalSearch>[0]> =
|
||||
describe("GlobalSearch", () => {
|
||||
beforeEach(() => {
|
||||
mocks.useQuery.mockReset();
|
||||
mocks.useCanRequest.mockReset();
|
||||
mocks.useRequestSearch.mockReset();
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: false,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mocks.useRequestSearch.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mocks.useQuery.mockReturnValue({
|
||||
data: {
|
||||
total: 50,
|
||||
@@ -105,3 +143,194 @@ describe("GlobalSearch", () => {
|
||||
expect(lastCall.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GlobalSearch + RequestToAddSection wiring", () => {
|
||||
beforeEach(() => {
|
||||
mocks.useQuery.mockReset();
|
||||
mocks.useCanRequest.mockReset();
|
||||
mocks.useRequestSearch.mockReset();
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: false,
|
||||
isResolving: 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,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the section with libraryHadHits=true when library returned results", () => {
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
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,
|
||||
isResolving: false,
|
||||
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,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" });
|
||||
|
||||
const call = mocks.useRequestSearch.mock.calls[mocks.useRequestSearch.mock.calls.length - 1];
|
||||
expect(call?.[3]).toEqual({
|
||||
enabled: false,
|
||||
requireProfile: true,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mount RequestToAddSection when discovery is disabled", () => {
|
||||
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,
|
||||
isResolving: false,
|
||||
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,
|
||||
isResolving: false,
|
||||
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,
|
||||
isResolving: false,
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,13 +7,17 @@ import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { buildQueryCatalogHref } from "@/pages/catalogSearchParams";
|
||||
import type { BrowseItem } from "@/api/types";
|
||||
import { createCatalogSearchState, fetchCatalogPage } from "@/hooks/queries/catalog";
|
||||
import { useRequestSearch } from "@/hooks/queries/useRequests";
|
||||
import { useCanRequest } from "@/hooks/useCanRequest";
|
||||
import { catalogKeys } from "@/hooks/queries/keys";
|
||||
import { decodeThumbhash } from "@/lib/thumbhash";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Search } from "lucide-react";
|
||||
import { RequestToAddSection } from "./RequestToAddSection";
|
||||
|
||||
const PREVIEW_LIMIT = 8;
|
||||
const DEBOUNCE_MS = 200;
|
||||
const TMDB_DEBOUNCE_MS = 400;
|
||||
|
||||
function typeLabel(type: BrowseItem["type"]): string {
|
||||
switch (type) {
|
||||
@@ -100,6 +104,24 @@ export function GlobalSearch({
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const navigate = useViewTransitionNavigate();
|
||||
const debouncedQuery = useDebounce(query.trim(), DEBOUNCE_MS);
|
||||
const tmdbDebouncedQuery = useDebounce(query.trim(), TMDB_DEBOUNCE_MS);
|
||||
const canRequest = useCanRequest();
|
||||
const tmdbQuery = useRequestSearch("all", tmdbDebouncedQuery, 1, {
|
||||
enabled: canRequest.discoveryEnabled,
|
||||
requireProfile: true,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
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 }),
|
||||
@@ -179,7 +201,11 @@ export function GlobalSearch({
|
||||
!previewQuery.isFetching &&
|
||||
debouncedQuery.length > 0 &&
|
||||
items.length === 0 &&
|
||||
!previewQuery.isError;
|
||||
!previewQuery.isError &&
|
||||
!tmdbStillLoading &&
|
||||
!tmdbWillRender &&
|
||||
!canRequest.isResolving &&
|
||||
!tmdbDebounceCatchingUp;
|
||||
const showError = previewQuery.isError;
|
||||
|
||||
return (
|
||||
@@ -236,37 +262,45 @@ export function GlobalSearch({
|
||||
</form>
|
||||
{showResultsPanel && (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
role="listbox"
|
||||
className="max-h-[min(22rem,55vh)] overflow-y-auto overscroll-contain px-2 py-2"
|
||||
>
|
||||
{showLoading && (
|
||||
<div className="text-muted-foreground px-3 py-6 text-center text-sm">
|
||||
Searching...
|
||||
</div>
|
||||
)}
|
||||
{showError && (
|
||||
<div className="text-destructive px-3 py-4 text-center text-sm">
|
||||
Could not load results. Press Enter to open the search page.
|
||||
</div>
|
||||
)}
|
||||
{showEmpty && (
|
||||
<div className="text-muted-foreground px-3 py-6 text-center text-sm">
|
||||
No matches
|
||||
</div>
|
||||
)}
|
||||
{items.map((item, i) => (
|
||||
<GlobalSearchResultRow
|
||||
key={item.content_id}
|
||||
item={item}
|
||||
index={i}
|
||||
isSelected={i === selectedIndex}
|
||||
onPick={handlePickItem}
|
||||
<div className="max-h-[min(22rem,55vh)] overflow-y-auto overscroll-contain px-2 py-2">
|
||||
<div role="listbox">
|
||||
{showLoading && (
|
||||
<div className="text-muted-foreground px-3 py-6 text-center text-sm">
|
||||
Searching...
|
||||
</div>
|
||||
)}
|
||||
{showError && (
|
||||
<div className="text-destructive px-3 py-4 text-center text-sm">
|
||||
Could not load results. Press Enter to open the search page.
|
||||
</div>
|
||||
)}
|
||||
{showEmpty && (
|
||||
<div className="text-muted-foreground px-3 py-6 text-center text-sm">
|
||||
No matches
|
||||
</div>
|
||||
)}
|
||||
{items.map((item, i) => (
|
||||
<GlobalSearchResultRow
|
||||
key={item.content_id}
|
||||
item={item}
|
||||
index={i}
|
||||
isSelected={i === selectedIndex}
|
||||
onPick={handlePickItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{tmdbDebouncedQuery.length > 1 && canRequest.discoveryEnabled && (
|
||||
<RequestToAddSection
|
||||
variant="dialog"
|
||||
query={tmdbDebouncedQuery}
|
||||
libraryHadHits={items.length > 0}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{items.length} results found
|
||||
{tmdbVisibleCount > 0
|
||||
? `${items.length} library results, ${tmdbVisibleCount} request suggestions`
|
||||
: `${items.length} results found`}
|
||||
</div>
|
||||
<div className="text-muted-foreground border-t px-3 py-2 text-center text-xs">
|
||||
{total > PREVIEW_LIMIT ? (
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<RequestPosterCard
|
||||
variant="discover"
|
||||
item={requestable}
|
||||
isSubmitting={false}
|
||||
onRequest={() => {}}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
// Must render an actual <button> with the "Request" label, not just any "Request"
|
||||
// substring (the /requests/... URL would match a naive includes check).
|
||||
expect(markup).toMatch(/<button[^>]*>[\s\S]*?Request[\s\S]*?<\/button>/);
|
||||
});
|
||||
|
||||
it("does not render the hover Request button when onRequest is omitted", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<RequestPosterCard variant="discover" item={requestable} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// The discover variant only contains one <button> (the hover Request action);
|
||||
// its absence is the strongest signal that the button was suppressed.
|
||||
expect(markup).not.toContain("<button");
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{requestable && (
|
||||
{requestable && onRequest && (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 flex aspect-[2/3] translate-y-2 items-end justify-center bg-gradient-to-t from-black/85 via-black/45 to-transparent p-3 opacity-0 transition-all duration-200 ease-out group-focus-within/req-card:translate-y-0 group-focus-within/req-card:opacity-100 group-hover/req-card:translate-y-0 group-hover/req-card:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
disabled={Boolean(isSubmitting)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } 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(),
|
||||
useCreateMediaRequest: vi.fn(),
|
||||
useDebounce: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useCanRequest", () => ({
|
||||
useCanRequest: () => mocks.useCanRequest(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/queries/useRequests", () => ({
|
||||
useRequestSearch: (...args: unknown[]) => mocks.useRequestSearch(...args),
|
||||
useCreateMediaRequest: () => mocks.useCreateMediaRequest(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useDebounce", () => ({
|
||||
useDebounce: <T,>(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(
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter>{child}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const missingResult = (overrides: Partial<RequestMediaResult> = {}): RequestMediaResult => ({
|
||||
media_type: "movie",
|
||||
tmdb_id: 1,
|
||||
title: "Dune: Prophecy",
|
||||
year: 2024,
|
||||
availability: "missing",
|
||||
request: { requestable: true },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const availableResult = (overrides: Partial<RequestMediaResult> = {}): 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,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mocks.useDebounce.mockImplementation((v: unknown) => v);
|
||||
});
|
||||
|
||||
it("renders nothing when discovery is disabled", () => {
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: false,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false });
|
||||
|
||||
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
|
||||
expect(markup).toBe("");
|
||||
});
|
||||
|
||||
it("passes enabled=false to useRequestSearch when discovery is disabled so no network call fires", () => {
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: false,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false });
|
||||
|
||||
render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
|
||||
|
||||
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);
|
||||
expect(call?.[3]).toEqual({
|
||||
enabled: false,
|
||||
requireProfile: true,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes enabled=true to useRequestSearch when discovery is enabled", () => {
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mocks.useRequestSearch.mockReturnValue({
|
||||
data: { page: 1, total_pages: 1, total_results: 0, results: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
|
||||
|
||||
const call = mocks.useRequestSearch.mock.calls[mocks.useRequestSearch.mock.calls.length - 1];
|
||||
expect(call?.[3]).toEqual({
|
||||
enabled: true,
|
||||
requireProfile: true,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
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(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
|
||||
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(
|
||||
<RequestToAddSection variant="dialog" query="dune" libraryHadHits={false} />,
|
||||
);
|
||||
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", () => {
|
||||
// missingResult has tmdb_id 1, availableResult has tmdb_id 2. The DialogRow
|
||||
// renders item.title only as text content (never as a `title=` attribute), so
|
||||
// a substring check on `title="Dune"` would pass even with the filter removed.
|
||||
// Check the link target instead — it's a precise, filter-driven signal.
|
||||
mocks.useRequestSearch.mockReturnValue({
|
||||
data: {
|
||||
page: 1,
|
||||
total_pages: 1,
|
||||
total_results: 2,
|
||||
results: [availableResult(), missingResult()],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
|
||||
expect(markup).toContain("/requests/movie/1");
|
||||
expect(markup).not.toContain("/requests/movie/2");
|
||||
});
|
||||
|
||||
it("renders nothing when TMDB returned an error", () => {
|
||||
mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: true });
|
||||
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
|
||||
expect(markup).toBe("");
|
||||
});
|
||||
|
||||
it("keeps rendering cached TMDB results when a refetch errors", () => {
|
||||
mocks.useRequestSearch.mockReturnValue({
|
||||
data: { page: 1, total_pages: 1, total_results: 1, results: [missingResult()] },
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
});
|
||||
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
|
||||
expect(markup).toContain("Dune: Prophecy");
|
||||
});
|
||||
|
||||
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(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
|
||||
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(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
|
||||
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",
|
||||
// formatRequestReason recognises "quota_exceeded" (not "quota_exhausted");
|
||||
// assert on the produced label so a regression in that mapping is caught.
|
||||
request: { requestable: false, reason: "quota_exceeded" },
|
||||
}),
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
|
||||
|
||||
expect(markup).toContain("Quota Capped Movie");
|
||||
expect(markup).not.toContain("bg-amber-400/15");
|
||||
expect(markup).toContain("Limit reached");
|
||||
expect(markup).toContain('title="Limit reached"');
|
||||
});
|
||||
|
||||
it("prefers request status over reason when a row is already requested", () => {
|
||||
mocks.useRequestSearch.mockReturnValue({
|
||||
data: {
|
||||
page: 1,
|
||||
total_pages: 1,
|
||||
total_results: 1,
|
||||
results: [
|
||||
missingResult({
|
||||
tmdb_id: 8,
|
||||
title: "Already Pending Movie",
|
||||
request: { requestable: false, reason: "blocked", status: "pending" },
|
||||
}),
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
|
||||
|
||||
expect(markup).toContain("Already Pending Movie");
|
||||
expect(markup).toContain("Pending");
|
||||
expect(markup).toContain('title="Pending"');
|
||||
expect(markup).not.toContain('title="Blocked"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("RequestToAddSection (grid variant)", () => {
|
||||
beforeEach(() => {
|
||||
mocks.useCanRequest.mockReset();
|
||||
mocks.useRequestSearch.mockReset();
|
||||
mocks.useCreateMediaRequest.mockReset();
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mocks.useCreateMediaRequest.mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
variables: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
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(<RequestToAddSection variant="grid" query="dune" libraryHadHits />);
|
||||
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(
|
||||
<RequestToAddSection variant="grid" query="dune" libraryHadHits={false} />,
|
||||
);
|
||||
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(<RequestToAddSection variant="grid" query="dune" libraryHadHits />);
|
||||
expect(markup).toContain("Result 0");
|
||||
expect(markup).toContain("Result 19");
|
||||
expect(markup).not.toContain("Result 20");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Film, Sparkles, Tv } from "lucide-react";
|
||||
import { useCanRequest } from "@/hooks/useCanRequest";
|
||||
import { useCreateMediaRequest, useRequestSearch } from "@/hooks/queries/useRequests";
|
||||
import type { RequestMediaResult } from "@/api/types";
|
||||
import {
|
||||
formatRequestReason,
|
||||
formatRequestStatus,
|
||||
requestInputFromMediaResult,
|
||||
tmdbImageURL,
|
||||
} from "@/lib/mediaRequests";
|
||||
import { cn } from "@/lib/utils";
|
||||
import RequestPosterCard from "./RequestPosterCard";
|
||||
|
||||
function cardKey(item: Pick<RequestMediaResult, "media_type" | "tmdb_id">): string {
|
||||
return `${item.media_type}-${item.tmdb_id}`;
|
||||
}
|
||||
|
||||
function nonRequestableLabel(item: RequestMediaResult): string {
|
||||
if (item.request.status) {
|
||||
return formatRequestStatus(item.request.status);
|
||||
}
|
||||
if (item.request.reason) {
|
||||
return formatRequestReason(item.request.reason);
|
||||
}
|
||||
return "Blocked";
|
||||
}
|
||||
|
||||
const DIALOG_LIMIT = 4;
|
||||
const GRID_LIMIT = 20;
|
||||
|
||||
export type RequestToAddSectionProps = {
|
||||
variant: "dialog" | "grid";
|
||||
query: string;
|
||||
/** True when the library search returned at least one hit. Drives header copy. */
|
||||
libraryHadHits: boolean;
|
||||
};
|
||||
|
||||
export function RequestToAddSection({ variant, query, libraryHadHits }: RequestToAddSectionProps) {
|
||||
const { discoveryEnabled } = useCanRequest();
|
||||
const search = useRequestSearch("all", query, 1, {
|
||||
enabled: discoveryEnabled,
|
||||
requireProfile: true,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (!discoveryEnabled) return null;
|
||||
if (search.isError && !search.data) 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 <DialogVariant items={visible} libraryHadHits={libraryHadHits} />;
|
||||
}
|
||||
return <GridVariant items={visible} libraryHadHits={libraryHadHits} />;
|
||||
}
|
||||
|
||||
function HeaderCopy({ libraryHadHits, count }: { libraryHadHits: boolean; count: number }) {
|
||||
if (libraryHadHits) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex items-center gap-2 px-3 pt-2 pb-1 text-[10px] font-medium tracking-[0.1em] uppercase">
|
||||
<span>Request to Add</span>
|
||||
<span className="bg-muted text-muted-foreground rounded-full px-1.5 text-[10px]">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 pt-3 pb-1 text-[12px] text-amber-300/85">
|
||||
Not in your library, but you can request:
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogVariant({
|
||||
items,
|
||||
libraryHadHits,
|
||||
}: {
|
||||
items: RequestMediaResult[];
|
||||
libraryHadHits: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-t border-white/5 pt-1">
|
||||
<HeaderCopy libraryHadHits={libraryHadHits} count={items.length} />
|
||||
<ul className="px-1 py-1">
|
||||
{items.map((item) => (
|
||||
<li key={`${item.media_type}-${item.tmdb_id}`}>
|
||||
<DialogRow item={item} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 unavailableLabel = requestable ? null : nonRequestableLabel(item);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/requests/${item.media_type}/${item.tmdb_id}`}
|
||||
className="hover:bg-muted/80 flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-muted relative h-14 w-10 shrink-0 overflow-hidden rounded-md",
|
||||
!requestable && "opacity-70",
|
||||
)}
|
||||
>
|
||||
{poster ? (
|
||||
<img src={poster} alt="" className="h-full w-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{item.title}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{item.year ? `${item.year} · ` : ""}
|
||||
{item.media_type === "series" ? "Series" : "Movie"}
|
||||
</div>
|
||||
</div>
|
||||
{requestable ? (
|
||||
<span className="rounded-full border border-amber-400/30 bg-amber-400/15 px-2 py-0.5 text-[9px] font-semibold tracking-[0.5px] text-amber-300 uppercase">
|
||||
Request
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[9px] font-medium tracking-[0.5px] text-white/60 uppercase"
|
||||
title={unavailableLabel ?? "Blocked"}
|
||||
>
|
||||
{unavailableLabel}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function GridVariant({
|
||||
items,
|
||||
libraryHadHits,
|
||||
}: {
|
||||
items: RequestMediaResult[];
|
||||
libraryHadHits: boolean;
|
||||
}) {
|
||||
const count = items.length;
|
||||
const createRequest = useCreateMediaRequest();
|
||||
// Track each in-flight card key independently; the shared `useMutation`
|
||||
// observer overwrites its `variables` on every `mutate` call, so rapid
|
||||
// clicks on different cards would otherwise trample each other's spinner.
|
||||
const [pendingKeys, setPendingKeys] = useState<ReadonlySet<string>>(new Set());
|
||||
const submitCard = (item: RequestMediaResult) => {
|
||||
const key = cardKey(item);
|
||||
setPendingKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(key);
|
||||
return next;
|
||||
});
|
||||
createRequest.mutate(requestInputFromMediaResult(item), {
|
||||
onSettled: () => {
|
||||
setPendingKeys((prev) => {
|
||||
if (!prev.has(key)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-[28px] border border-amber-400/[0.14]",
|
||||
"bg-[radial-gradient(120%_60%_at_50%_0%,rgba(245,158,11,0.07)_0%,rgba(245,158,11,0.015)_45%,transparent_75%)]",
|
||||
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.04),0_28px_60px_-44px_rgba(0,0,0,0.7)]",
|
||||
"px-4 pt-7 pb-7 sm:px-7 sm:pt-8",
|
||||
libraryHadHits && "mt-12! sm:mt-16!",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-16 top-0 h-px bg-gradient-to-r from-transparent via-amber-300/45 to-transparent"
|
||||
/>
|
||||
|
||||
<header className="mb-7 flex flex-wrap items-end justify-between gap-x-5 gap-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-amber-200/85">
|
||||
<Sparkles className="h-3.5 w-3.5" strokeWidth={2.2} aria-hidden />
|
||||
<span className="text-[10px] font-semibold tracking-[0.24em] uppercase">
|
||||
{libraryHadHits ? "Discover · Outside your library" : "Outside your library"}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-display text-foreground text-[clamp(1.25rem,1.6vw,1.55rem)] leading-tight font-semibold tracking-tight">
|
||||
{libraryHadHits ? "Request to Add" : "Not in your library, but you can request"}
|
||||
</h2>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 self-end rounded-full border border-amber-400/15 bg-amber-400/[0.06] px-2.5 py-1 text-[11px] font-medium tracking-wide text-amber-100/75 tabular-nums">
|
||||
{count} {count === 1 ? "result" : "results"}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{items.map((item) => {
|
||||
const key = cardKey(item);
|
||||
return (
|
||||
<RequestPosterCard
|
||||
key={key}
|
||||
variant="discover"
|
||||
item={item}
|
||||
isSubmitting={pendingKeys.has(key)}
|
||||
onRequest={() => submitCard(item)}
|
||||
fluid
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>) => ["requests", "mine", params] as const,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
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(),
|
||||
useCurrentProfile: vi.fn(),
|
||||
api: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@tanstack/react-query")>("@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(<QueryClientProvider client={client}>{node}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
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(<CallHook mediaType="all" q="dune" />);
|
||||
|
||||
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(<CallHook mediaType="movie" q="dune" />);
|
||||
|
||||
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(<CallHook mediaType="all" q="dune" />);
|
||||
|
||||
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("keeps the existing Requests page staleTime by default", () => {
|
||||
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } });
|
||||
render(<CallHook mediaType="all" q="dune" />);
|
||||
|
||||
const options = mocks.useQuery.mock.calls[0]![0] as { staleTime: number };
|
||||
expect(options.staleTime).toBe(30 * 1000);
|
||||
});
|
||||
|
||||
it("allows callers to opt into a longer staleTime", () => {
|
||||
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } });
|
||||
|
||||
function CallHookWithStaleTime() {
|
||||
useRequestSearch("all", "dune", 1, { staleTime: 5 * 60 * 1000 });
|
||||
return null;
|
||||
}
|
||||
render(<CallHookWithStaleTime />);
|
||||
|
||||
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(<CallHookWithOpt enabled={false} />);
|
||||
|
||||
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(<CallHook mediaType="all" q="dune" />);
|
||||
|
||||
const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean };
|
||||
expect(options.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("does not require profile by default", () => {
|
||||
mocks.useCurrentProfile.mockReturnValue({ profile: null });
|
||||
render(<CallHook mediaType="all" q="dune" />);
|
||||
|
||||
const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean };
|
||||
expect(options.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("can require a profile before fetching", () => {
|
||||
mocks.useCurrentProfile.mockReturnValue({ profile: null });
|
||||
|
||||
function CallHookRequiringProfile() {
|
||||
useRequestSearch("all", "dune", 1, { requireProfile: true });
|
||||
return null;
|
||||
}
|
||||
render(<CallHookRequiringProfile />);
|
||||
|
||||
const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean };
|
||||
expect(options.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
@@ -51,6 +52,9 @@ function buildListQuery(params: RequestListParams = {}) {
|
||||
}
|
||||
|
||||
function invalidateRequestSurfaces(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
// 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() });
|
||||
}
|
||||
@@ -148,20 +152,44 @@ 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;
|
||||
/** When true, suppresses the query until the active profile is loaded. Default: false. */
|
||||
requireProfile?: boolean;
|
||||
/** Cache freshness window for this search surface. Default: existing Requests page timing. */
|
||||
staleTime?: number;
|
||||
}
|
||||
|
||||
export function useRequestSearch(
|
||||
mediaType: RequestSearchMediaType,
|
||||
query: string,
|
||||
page = 1,
|
||||
options: UseRequestSearchOptions = {},
|
||||
) {
|
||||
const normalizedQuery = query.trim();
|
||||
const { profile } = useCurrentProfile();
|
||||
// Use a sentinel viewerKey when there is no profile so the cache key is stable,
|
||||
// but suppress the actual fetch — see the `enabled` gate below. This prevents
|
||||
// any anonymous request results from being written into a bucket that could
|
||||
// later be read by a different viewer.
|
||||
const viewerKey = profile?.id ?? "anon";
|
||||
const enabledOverride = options.enabled ?? true;
|
||||
const requireProfile = options.requireProfile ?? false;
|
||||
|
||||
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<RequestMediaPage>(`/requests/search?${params}`);
|
||||
return api<RequestMediaPage>(`/requests/search?${params}`, { signal });
|
||||
},
|
||||
enabled: normalizedQuery.length > 1,
|
||||
staleTime: REQUESTS_STALE_TIME,
|
||||
enabled:
|
||||
enabledOverride && normalizedQuery.length > 1 && (!requireProfile || Boolean(profile?.id)),
|
||||
staleTime: options.staleTime ?? REQUESTS_STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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<typeof useCanRequest>) => void }) {
|
||||
const result = useCanRequest();
|
||||
onResult(result);
|
||||
return null;
|
||||
}
|
||||
|
||||
function render(child: ReactNode) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return renderToStaticMarkup(<QueryClientProvider client={client}>{child}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe("useCanRequest", () => {
|
||||
it("returns discoveryEnabled=false when requests_enabled is false", () => {
|
||||
mocks.useRequestFeatureStatus.mockReturnValue({
|
||||
data: { requests_enabled: false },
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } });
|
||||
|
||||
let captured: ReturnType<typeof useCanRequest> | null = null;
|
||||
render(
|
||||
<CaptureHook
|
||||
onResult={(r) => {
|
||||
captured = r;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(captured).toEqual({
|
||||
discoveryEnabled: false,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns discoveryEnabled=false when there is no profile", () => {
|
||||
mocks.useRequestFeatureStatus.mockReturnValue({
|
||||
data: { requests_enabled: true },
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useCurrentProfile.mockReturnValue({ profile: null });
|
||||
|
||||
let captured: ReturnType<typeof useCanRequest> | null = null;
|
||||
render(
|
||||
<CaptureHook
|
||||
onResult={(r) => {
|
||||
captured = r;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(captured).toEqual({
|
||||
discoveryEnabled: false,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns discoveryEnabled=true when requests are enabled and there is a profile", () => {
|
||||
mocks.useRequestFeatureStatus.mockReturnValue({
|
||||
data: { requests_enabled: true },
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } });
|
||||
|
||||
let captured: ReturnType<typeof useCanRequest> | null = null;
|
||||
render(
|
||||
<CaptureHook
|
||||
onResult={(r) => {
|
||||
captured = r;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(captured).toEqual({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns isResolving=true and discoveryEnabled=false while the feature status is still loading", () => {
|
||||
mocks.useRequestFeatureStatus.mockReturnValue({ data: undefined, isLoading: true });
|
||||
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } });
|
||||
|
||||
let captured: ReturnType<typeof useCanRequest> | null = null;
|
||||
render(
|
||||
<CaptureHook
|
||||
onResult={(r) => {
|
||||
captured = r;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(captured).toEqual({
|
||||
discoveryEnabled: false,
|
||||
isResolving: true,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useRequestFeatureStatus } from "@/hooks/queries/useRequests";
|
||||
import { useCurrentProfile } from "@/hooks/useCurrentProfile";
|
||||
|
||||
export interface CanRequestState {
|
||||
discoveryEnabled: boolean;
|
||||
/**
|
||||
* True while we cannot yet decide if discovery is on — feature status
|
||||
* is still loading. Consumers should suppress empty-state UI in this
|
||||
* window to avoid a flash before the request section appears.
|
||||
*/
|
||||
isResolving: boolean;
|
||||
submitDisabledReason: string | null;
|
||||
}
|
||||
|
||||
export function useCanRequest(): CanRequestState {
|
||||
const status = useRequestFeatureStatus();
|
||||
const { profile } = useCurrentProfile();
|
||||
const discoveryEnabled = Boolean(status.data?.requests_enabled) && Boolean(profile?.id);
|
||||
const isResolving = status.isLoading;
|
||||
|
||||
return {
|
||||
discoveryEnabled,
|
||||
isResolving,
|
||||
submitDisabledReason: null,
|
||||
};
|
||||
}
|
||||
@@ -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<typeof import("react-router")>("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;
|
||||
}) => (
|
||||
<div data-testid="request-section">
|
||||
{`variant="${variant}" query="${query}" libraryHadHits="${String(libraryHadHits)}"`}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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 <div data-kind="item-grid">{props.items?.map((item) => item.title).join(",")}</div>;
|
||||
return (
|
||||
<div
|
||||
data-kind="item-grid"
|
||||
data-loading={String(Boolean(props.loading))}
|
||||
data-total={String(props.totalItems ?? 0)}
|
||||
>
|
||||
{props.items?.map((item) => item.title).join(",")}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -152,6 +187,14 @@ describe("Catalog page", () => {
|
||||
mockUseCatalogWindow.mockReset();
|
||||
mockUseCatalogFilters.mockReset();
|
||||
mockItemGrid.mockReset();
|
||||
mockUseCanRequest.mockReset();
|
||||
mockUseRequestSearch.mockReset();
|
||||
mockUseCanRequest.mockReturnValue({
|
||||
discoveryEnabled: false,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mockUseRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false });
|
||||
|
||||
mockUseCatalogWindow.mockReturnValue({
|
||||
data: {
|
||||
@@ -273,7 +316,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 +325,236 @@ describe("Catalog page", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
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,
|
||||
isResolving: false,
|
||||
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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<App />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
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,
|
||||
isResolving: false,
|
||||
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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<App />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
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,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mockUseCatalogWindow.mockReturnValue({
|
||||
data: { title: "Favorites", totalItems: 0, pages: new Map() },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<App />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(markup).not.toContain('data-testid="request-section"');
|
||||
});
|
||||
|
||||
it("does not render the request section when discovery is disabled", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<App />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(markup).not.toContain('data-testid="request-section"');
|
||||
});
|
||||
|
||||
it("passes enabled=false to useRequestSearch when discoveryEnabled is false", () => {
|
||||
renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<App />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const call = mockUseRequestSearch.mock.calls[mockUseRequestSearch.mock.calls.length - 1];
|
||||
expect(call?.[3]).toEqual({
|
||||
enabled: false,
|
||||
requireProfile: true,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("hides ItemGrid when library is empty and TMDB is still loading (request section will rescue)", () => {
|
||||
mockUseCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<App />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Previously this case forced ItemGrid into loading=true, rendering 24 fake
|
||||
// skeletons forever above the section. Now ItemGrid is hidden entirely.
|
||||
expect(markup).not.toContain('data-kind="item-grid"');
|
||||
});
|
||||
|
||||
it("hides ItemGrid when library is empty and TMDB has missing results", () => {
|
||||
mockUseCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<App />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(markup).not.toContain('data-kind="item-grid"');
|
||||
expect(markup).toContain('data-testid="request-section"');
|
||||
});
|
||||
|
||||
it("hides ItemGrid when library is empty and discovery feature status is still resolving", () => {
|
||||
mockUseCanRequest.mockReturnValue({
|
||||
discoveryEnabled: false,
|
||||
isResolving: true,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mockUseCatalogWindow.mockReturnValue({
|
||||
data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<App />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Avoids the empty-state flash before the feature status resolves and TMDB
|
||||
// either rescues with results or confirms there are none.
|
||||
expect(markup).not.toContain('data-kind="item-grid"');
|
||||
});
|
||||
|
||||
it("renders the normal ItemGrid empty state when both library and TMDB are empty", () => {
|
||||
mockUseCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<App />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-loading="false"');
|
||||
expect(markup).toContain('data-total="0"');
|
||||
});
|
||||
});
|
||||
|
||||
+46
-10
@@ -4,10 +4,14 @@ 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 { useDebounce } from "@/hooks/useDebounce";
|
||||
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
|
||||
import SearchBar from "@/components/SearchBar";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
@@ -101,6 +105,28 @@ function CatalogResults({
|
||||
visibleRange,
|
||||
includeTotal: showExactResultCount,
|
||||
});
|
||||
const canRequest = useCanRequest();
|
||||
const isQuerySource = state.source === "query" && Boolean(state.q);
|
||||
// Add a 200ms TMDB debounce on top of SearchBar's 200ms input debounce so the
|
||||
// TMDB plugin isn't hit at the same cadence as the local library query.
|
||||
const tmdbDebouncedQ = useDebounce(state.q ?? "", 200);
|
||||
const tmdbQuery = useRequestSearch("all", tmdbDebouncedQ, 1, {
|
||||
enabled: canRequest.discoveryEnabled && isQuerySource,
|
||||
requireProfile: true,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const tmdbMissingCount =
|
||||
tmdbQuery.data?.results?.filter((result) => result.availability !== "available").length ?? 0;
|
||||
const libraryHasResults = (catalogQuery.data?.totalItems ?? 0) > 0;
|
||||
const libraryEmpty = !catalogQuery.isLoading && !libraryHasResults;
|
||||
// When the library is empty and the request section will (or might) render,
|
||||
// hide ItemGrid entirely. The previous approach pinned ItemGrid's `loading`
|
||||
// prop to true, which renders 24 skeleton tiles forever above the section.
|
||||
const tmdbMayRescueLibrary =
|
||||
isQuerySource &&
|
||||
libraryEmpty &&
|
||||
(canRequest.isResolving ||
|
||||
(canRequest.discoveryEnabled && (tmdbQuery.isLoading || tmdbMissingCount > 0)));
|
||||
const loadedHistoryItems = useMemo(() => {
|
||||
if (!isHistorySource) {
|
||||
return [] as BrowseItem[];
|
||||
@@ -249,16 +275,26 @@ function CatalogResults({
|
||||
</section>
|
||||
)}
|
||||
|
||||
<ItemGrid
|
||||
totalItems={catalogQuery.data?.totalItems ?? 0}
|
||||
pages={catalogQuery.data?.pages ?? new Map()}
|
||||
pageSize={limit}
|
||||
loading={catalogQuery.isLoading}
|
||||
onVisibleRangeChange={handleVisibleRangeChange}
|
||||
selectionMode={isHistorySource && selectionMode}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={toggleHistorySelection}
|
||||
/>
|
||||
{tmdbMayRescueLibrary ? null : (
|
||||
<ItemGrid
|
||||
totalItems={catalogQuery.data?.totalItems ?? 0}
|
||||
pages={catalogQuery.data?.pages ?? new Map()}
|
||||
pageSize={limit}
|
||||
loading={catalogQuery.isLoading}
|
||||
onVisibleRangeChange={handleVisibleRangeChange}
|
||||
selectionMode={isHistorySource && selectionMode}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={toggleHistorySelection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isQuerySource && canRequest.discoveryEnabled ? (
|
||||
<RequestToAddSection
|
||||
variant="grid"
|
||||
query={tmdbDebouncedQ}
|
||||
libraryHadHits={libraryHasResults}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={removeConfirmOpen}
|
||||
|
||||
Reference in New Issue
Block a user