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} +