feat(catalog): render RequestToAddSection grid with empty-state suppression

This commit is contained in:
Silo Server Migration
2026-05-25 18:19:42 -04:00
parent b78b43e958
commit a64f41da45
2 changed files with 245 additions and 4 deletions
+219 -3
View File
@@ -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,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", () => {
</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, 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=&quot;grid&quot;");
expect(markup).toContain("libraryHadHits=&quot;true&quot;");
});
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(
<QueryClientProvider client={new QueryClient()}>
<App />
</QueryClientProvider>,
);
expect(markup).toContain("libraryHadHits=&quot;false&quot;");
});
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(
<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.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(
<QueryClientProvider client={new QueryClient()}>
<App />
</QueryClientProvider>,
);
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(
<QueryClientProvider client={new QueryClient()}>
<App />
</QueryClientProvider>,
);
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(
<QueryClientProvider client={new QueryClient()}>
<App />
</QueryClientProvider>,
);
expect(markup).toContain('data-loading="false"');
expect(markup).toContain('data-total="0"');
});
});
+26 -1
View File
@@ -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 ? (
<RequestToAddSection
variant="grid"
query={state.q!}
libraryHadHits={(catalogQuery.data?.totalItems ?? 0) > 0}
/>
) : null}
<ConfirmDialog
open={removeConfirmOpen}
onOpenChange={setRemoveConfirmOpen}