feat(search): render RequestToAddSection in the Cmd+K dialog with empty-state suppression
This commit is contained in:
@@ -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,14 @@ 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, 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 && (
|
||||
<RequestToAddSection
|
||||
variant="dialog"
|
||||
query={tmdbDebouncedQuery}
|
||||
libraryHadHits={items.length > 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{items.length} results found
|
||||
|
||||
Reference in New Issue
Block a user