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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user