From bcc28795e4a6e8c6cf5a48e134164b2ca2bd046f Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 18:17:31 -0400 Subject: [PATCH] feat(search): render RequestToAddSection in the Cmd+K dialog with empty-state suppression --- web/src/components/GlobalSearch.test.tsx | 189 +++++++++++++++++++++++ web/src/components/GlobalSearch.tsx | 25 ++- 2 files changed, 213 insertions(+), 1 deletion(-) diff --git a/web/src/components/GlobalSearch.test.tsx b/web/src/components/GlobalSearch.test.tsx index d279c894..dd3c2083 100644 --- a/web/src/components/GlobalSearch.test.tsx +++ b/web/src/components/GlobalSearch.test.tsx @@ -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: (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; + }) => ( +
+ {`variant="${variant}" query="${query}" libraryHadHits="${String(libraryHadHits)}"`} +
+ ), +})); + vi.mock("@/components/ui/dialog", () => ({ Dialog: ({ children, open }: { children: ReactNode; open: boolean }) => open ?
{children}
: null, @@ -66,6 +92,14 @@ function renderSearchMarkup(props: Partial[0]> = describe("GlobalSearch", () => { 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, @@ -105,3 +139,158 @@ 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, 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, 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("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({ + 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"); + }); +}); diff --git a/web/src/components/GlobalSearch.tsx b/web/src/components/GlobalSearch.tsx index 772b638b..9d910a55 100644 --- a/web/src/components/GlobalSearch.tsx +++ b/web/src/components/GlobalSearch.tsx @@ -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,16 @@ 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, + }); + const tmdbMissingCount = + tmdbQuery.data?.results?.filter((result) => result.availability !== "available").length ?? 0; + const tmdbStillLoading = + canRequest.discoveryEnabled && tmdbDebouncedQuery.length > 1 && tmdbQuery.isLoading; + const tmdbWillRender = canRequest.discoveryEnabled && tmdbMissingCount > 0; const searchState = useMemo( () => createCatalogSearchState("query", { q: debouncedQuery || undefined }), @@ -179,7 +193,9 @@ export function GlobalSearch({ !previewQuery.isFetching && debouncedQuery.length > 0 && items.length === 0 && - !previewQuery.isError; + !previewQuery.isError && + !tmdbStillLoading && + !tmdbWillRender; const showError = previewQuery.isError; return ( @@ -264,6 +280,13 @@ export function GlobalSearch({ onPick={handlePickItem} /> ))} + {tmdbDebouncedQuery.length > 1 && canRequest.discoveryEnabled && ( + 0} + /> + )}
{items.length} results found