fix(plex): complete the 409->nameTaken sweep + cover PlexLibraryPicker

The notifyCollectionEditError helper localizes the backend's duplicate-name
409 to plex.collection.nameTaken; it was applied to the New-collection panel
but two sibling collection-write sites still routed 409 through
notifyActionError, surfacing the server's raw English detail under the HU UI:

- PlexCollectionView.doRename (rename collision)
- PlexCollectionEditor.createNew (create-in-editor duplicate name)

Both now use notifyCollectionEditError, matching the create path. Also adds a
direct dom test for the extracted PlexLibraryPicker covering the enabled gate,
emptyHint, results, honest error, and no-results branches.
This commit is contained in:
2026-08-23 18:39:22 +02:00
parent 81b7660232
commit 2d1a7ff374
3 changed files with 108 additions and 5 deletions
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Plus } from "lucide-react"; import { Check, Plus } from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors"; import { notifyCollectionEditError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade"; import { useScrollFade } from "../lib/useScrollFade";
import Modal from "./Modal"; import Modal from "./Modal";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
@@ -102,8 +102,9 @@ export default function PlexCollectionEditor({
setMembers((prev) => new Set(prev).add(col.id)); setMembers((prev) => new Set(prev).add(col.id));
refresh(); refresh();
} catch (e) { } catch (e) {
// Surfaces the 409 when the name duplicates an existing collection (Plex conflates same names). // The backend rejects a duplicate name with 409 (Plex conflates same names) — localize it to
notifyActionError(e, t("plex.collection.editFailed")); // nameTaken rather than surfacing the server's raw English detail.
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken"));
} finally { } finally {
setBusy(null); setBusy(null);
} }
@@ -5,7 +5,7 @@ import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "luc
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api"; import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api";
import { formatRuntime } from "../lib/format"; import { formatRuntime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError, notifyCollectionEditError } from "../lib/actionErrors";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi"; import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
import { PlexPosterCard } from "./plexCards"; import { PlexPosterCard } from "./plexCards";
@@ -99,7 +99,8 @@ export default function PlexCollectionView({
invalidate(); invalidate();
setRenaming(false); setRenaming(false);
} catch (e) { } 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 { } finally {
setBusy(false); setBusy(false);
} }
@@ -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<typeof import("react-i18next")>();
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<typeof card>[]) => ({
scope: "both",
total: items.length,
offset: 0,
limit: 12,
items,
episodes: [],
});
beforeEach(() => {
plexLibrary.mockReset();
plexLibrary.mockResolvedValue(result([]));
});
afterEach(cleanup);
const renderPicker = (props: Partial<ComponentProps<typeof PlexLibraryPicker>> = {}) => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<PlexLibraryPicker
scope="both"
term=""
renderAction={(c) => <button>act:{c.id}</button>}
{...props}
/>
</QueryClientProvider>,
);
};
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: <p>EMPTY_HINT</p> });
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());
});
});