feat(search): render RequestToAddSection in the Cmd+K dialog with empty-state suppression

This commit is contained in:
Silo Server Migration
2026-05-25 18:17:31 -04:00
parent c5e2770bab
commit bcc28795e4
2 changed files with 213 additions and 1 deletions
+189
View File
@@ -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=&quot;true&quot;");
expect(markup).toContain("variant=&quot;dialog&quot;");
});
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=&quot;false&quot;");
});
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");
});
});
+24 -1
View File
@@ -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