feat(catalog): render RequestToAddSection grid with empty-state suppression
This commit is contained in:
@@ -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="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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<App />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user