diff --git a/frontend/src/components/PlexCollectionEditor.tsx b/frontend/src/components/PlexCollectionEditor.tsx index 9b3df1ee..17ba8b76 100644 --- a/frontend/src/components/PlexCollectionEditor.tsx +++ b/frontend/src/components/PlexCollectionEditor.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Plus } from "lucide-react"; import { api } from "../lib/api"; -import { notifyActionError } from "../lib/actionErrors"; +import { notifyCollectionEditError } from "../lib/actionErrors"; import { useScrollFade } from "../lib/useScrollFade"; import Modal from "./Modal"; import { useConfirm } from "./ConfirmProvider"; @@ -102,8 +102,9 @@ export default function PlexCollectionEditor({ setMembers((prev) => new Set(prev).add(col.id)); refresh(); } catch (e) { - // Surfaces the 409 when the name duplicates an existing collection (Plex conflates same names). - notifyActionError(e, t("plex.collection.editFailed")); + // The backend rejects a duplicate name with 409 (Plex conflates same names) — localize it to + // nameTaken rather than surfacing the server's raw English detail. + notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken")); } finally { setBusy(null); } diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index 65788245..052680d3 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -5,7 +5,7 @@ import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "luc import { qk } from "../lib/queryKeys"; import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api"; import { formatRuntime } from "../lib/format"; -import { notifyActionError } from "../lib/actionErrors"; +import { notifyActionError, notifyCollectionEditError } from "../lib/actionErrors"; import { useConfirm } from "./ConfirmProvider"; import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi"; import { PlexPosterCard } from "./plexCards"; @@ -99,7 +99,8 @@ export default function PlexCollectionView({ invalidate(); setRenaming(false); } catch (e) { - notifyActionError(e, t("plex.collection.editFailed")); + // The backend enforces per-library name uniqueness — localize its 409 to nameTaken. + notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken")); } finally { setBusy(false); } diff --git a/frontend/src/components/PlexLibraryPicker.dom.test.tsx b/frontend/src/components/PlexLibraryPicker.dom.test.tsx new file mode 100644 index 00000000..4d6887b3 --- /dev/null +++ b/frontend/src/components/PlexLibraryPicker.dom.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +// +// Contract of the shared library search-and-pick list (F6): it owns the debounced query and the +// full loading/error/empty/no-results/list state machine so the two callers (AddItemPanel in +// PlexCollectionView, NewCollectionPanel in plexBrowseLists) can't drift. The two things a caller +// steers — the `enabled` gate (NewCollectionPanel holds it off until the collection name is valid) +// and the `emptyHint` shown before a term is typed — are the pieces with no test before this file, so +// they're covered here. i18n is key-passthrough and api/debounce are mocked so the test drives state +// directly. +import { type ComponentProps } from "react"; +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { render, screen, cleanup, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; +}); +// Identity debounce so a passed term is reflected immediately. +vi.mock("../lib/useDebounced", () => ({ useDebounced: (v: unknown) => v })); + +// api.plexLibrary stub — returns whatever the current test queued. +const plexLibrary = vi.fn(); +vi.mock("../lib/api", () => ({ api: { plexLibrary: (...a: unknown[]) => plexLibrary(...a) } })); + +import PlexLibraryPicker from "./PlexLibraryPicker"; + +const card = (id: string, title: string, year = 2000) => ({ + id, + type: "movie" as const, + title, + year, + thumb: `/api/plex/image/${id}`, +}); +const result = (items: ReturnType[]) => ({ + scope: "both", + total: items.length, + offset: 0, + limit: 12, + items, + episodes: [], +}); + +beforeEach(() => { + plexLibrary.mockReset(); + plexLibrary.mockResolvedValue(result([])); +}); +afterEach(cleanup); + +const renderPicker = (props: Partial> = {}) => { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + } + {...props} + /> + , + ); +}; + +describe("PlexLibraryPicker", () => { + it("renders nothing and fires no query while disabled, even with a term", () => { + const { container } = renderPicker({ enabled: false, term: "matrix" }); + expect(container.firstChild).toBeNull(); + expect(plexLibrary).not.toHaveBeenCalled(); + }); + + it("shows the emptyHint (and fires no query) before a term is typed", () => { + renderPicker({ term: "", emptyHint:

EMPTY_HINT

}); + expect(screen.getByText("EMPTY_HINT")).toBeTruthy(); + expect(plexLibrary).not.toHaveBeenCalled(); + }); + + it("queries the library once enabled + a term are both present and renders the rows via renderAction", async () => { + plexLibrary.mockResolvedValue(result([card("m1", "The Matrix"), card("m2", "Reloaded")])); + renderPicker({ term: "matrix" }); + + await waitFor(() => expect(screen.getByText("act:m1")).toBeTruthy()); + expect(screen.getByText("act:m2")).toBeTruthy(); + expect(screen.getByText("The Matrix")).toBeTruthy(); + expect(plexLibrary).toHaveBeenCalledWith({ scope: "both", q: "matrix", limit: 12 }); + }); + + it("renders the honest error state (not an empty/no-results state) when the query fails", async () => { + plexLibrary.mockRejectedValue(new Error("boom")); + renderPicker({ term: "matrix" }); + + await waitFor(() => expect(screen.getByText("common.loadError")).toBeTruthy()); + expect(screen.queryByText("plex.collection.addNoResults")).toBeNull(); + }); + + it("shows the no-results message when the query returns an empty list", async () => { + plexLibrary.mockResolvedValue(result([])); + renderPicker({ term: "zzzz" }); + + await waitFor(() => expect(screen.getByText("plex.collection.addNoResults")).toBeTruthy()); + }); +});