fix(plex): a collection kept after a failed refresh holds its seed title
When Plex made the collection and only the read after it failed, the kept row had no members: the title showed unticked in the collection it had just been added to, the collection empty, and the name stayed in the field for a retry that could only be refused as taken. The seed's row now names the collection (child_count 1), and the panel clears the field on that refusal as on success. Also held: the panel's lines sit beside its list, and a keyboard Retry lands on the first row.
This commit is contained in:
@@ -470,8 +470,17 @@ def create_collection(
|
||||
# It is on Plex now, and only the follow-up read failed. Its row is kept, editable: the
|
||||
# full sync preserves `editable` but creates a missing row read-only, so the collection
|
||||
# would drop out of every "add to" list; and the duplicate-name check reads these rows,
|
||||
# so a retry of the same name would make a second one on Plex.
|
||||
db.add(PlexCollection(rating_key=new_rk, library_id=lib.id, title=title, editable=True))
|
||||
# so a retry of the same name would make a second one on Plex. Plex made it with exactly
|
||||
# the seed in it, so the seed's row says so too — otherwise the title showed unticked in
|
||||
# the collection it was just added to, and the collection empty, until that sync.
|
||||
db.add(
|
||||
PlexCollection(
|
||||
rating_key=new_rk, library_id=lib.id, title=title, editable=True, child_count=1
|
||||
)
|
||||
)
|
||||
Model = PlexItem if lib.kind == "movie" else PlexShow
|
||||
if row := by_rating_key(db, Model, seed):
|
||||
row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), new_rk]))
|
||||
db.commit()
|
||||
raise _write_or_pending_error(wrote, e) from e
|
||||
return _collection_card(col)
|
||||
|
||||
@@ -360,6 +360,9 @@ def test_create_resync_failure_is_422(db, monkeypatch, override_dep):
|
||||
# where collections are added to, and the same name is not made twice.
|
||||
row = by_rating_key(db, PlexCollection, "NEW")
|
||||
assert row is not None and row.editable and row.title == "Fresh Coll"
|
||||
# Plex made it with the seed in it: the seed's row says so, so the title shows ticked in it.
|
||||
assert row.child_count == 1
|
||||
assert "NEW" in (by_rating_key(db, PlexItem, "m1").collection_keys or [])
|
||||
again = client.post(
|
||||
"/api/plex/collections",
|
||||
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useLayoutEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, type PlexCollection } from "../lib/api";
|
||||
import { api, HttpError, type PlexCollection } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
@@ -82,6 +82,7 @@ export default function PlexCollectionAddAt({
|
||||
await api.plexCreateCollection("", name, ratingKey);
|
||||
made = true;
|
||||
} catch (e) {
|
||||
made = madeOnPlex(e);
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
}
|
||||
refresh();
|
||||
@@ -95,6 +96,7 @@ export default function PlexCollectionAddAt({
|
||||
popover={pop}
|
||||
className={`${SURFACE.popover} w-64 p-2`}
|
||||
ariaLabel={t("plex.collectionAdd.title")}
|
||||
listInside
|
||||
>
|
||||
<MembershipMenu
|
||||
heading={t("plex.collectionAdd.title")}
|
||||
@@ -118,6 +120,13 @@ export default function PlexCollectionAddAt({
|
||||
);
|
||||
}
|
||||
|
||||
/** A create refused only because the read after it failed: the collection is on Plex, with this
|
||||
* title in it, and its row is kept — the name is taken now, so the field clears as on success. */
|
||||
function madeOnPlex(e: unknown): boolean {
|
||||
const d = e instanceof HttpError ? (e.detailData as { reason?: string } | undefined) : undefined;
|
||||
return d?.reason === "collection_pending_sync";
|
||||
}
|
||||
|
||||
function flipContains<T extends { collections: { id: string; contains: boolean }[] }>(
|
||||
old: T | undefined,
|
||||
id: string,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { api, type PlexCard, type PlexCollection } from "../lib/api";
|
||||
import { api, HttpError, type PlexCard, type PlexCollection } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
@@ -361,9 +361,35 @@ describe("a Plex title's menu — Add to collection", () => {
|
||||
const alert = await screen.findByRole("alert", { hidden: true });
|
||||
expect(alert.textContent).toContain("plex.collectionAdd.loadFailed");
|
||||
expect(screen.queryByText("plex.collectionAdd.none")).toBeNull();
|
||||
// A menu owns its items only: the line about the list sits beside it, not in it.
|
||||
expect(document.querySelector("[role=menu]")).not.toBeNull();
|
||||
expect(alert.closest("[role=menu]")).toBeNull();
|
||||
list.mockResolvedValue({ collections: [coll("cB", "Beta", false)] });
|
||||
fireEvent.click(await screen.findByText("common.retry"));
|
||||
await checkRow("Beta");
|
||||
// Retry from the keyboard: the focused button goes as the list loads, and focus must not be left
|
||||
// on <body> with the menu open — it lands on the first row that came back.
|
||||
const retry = (await screen.findByText("common.retry")).closest("button")!;
|
||||
retry.focus();
|
||||
fireEvent.click(retry);
|
||||
const beta = await checkRow("Beta");
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(beta));
|
||||
});
|
||||
|
||||
it("clears the name when Plex made the collection and only the read after it failed", async () => {
|
||||
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({ collections: [] });
|
||||
const create = vi
|
||||
.spyOn(api, "plexCreateCollection")
|
||||
.mockRejectedValue(
|
||||
new HttpError(422, "pending", { code: "refused", reason: "collection_pending_sync" }),
|
||||
);
|
||||
setup(card({}), {}, { admin: true });
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.collectionAdd.row"));
|
||||
const name = await screen.findByLabelText<HTMLInputElement>("plex.collection.namePlaceholder");
|
||||
fireEvent.change(name, { target: { value: "Noir" } });
|
||||
fireEvent.keyDown(name, { key: "Enter" });
|
||||
await vi.waitFor(() => expect(create).toHaveBeenCalledTimes(1));
|
||||
// It exists now: a second Enter on the kept name could only be refused as taken.
|
||||
await vi.waitFor(() => expect(name.value).toBe(""));
|
||||
});
|
||||
|
||||
it("steps from the name field into the rows with the arrows — rows that arrived after it opened", async () => {
|
||||
|
||||
Reference in New Issue
Block a user