fix(playlists): an Undo puts a row back after its old neighbour, not at a stale index
A raw index recorded at removal drifted once anything changed before the Undo. Both lists now read the list fresh and re-insert each row after the row that preceded it (lib/reinsert). A failed watched toggle in a Plex playlist also puts the row's state back: its local copy was never re-seeded.
This commit is contained in:
@@ -449,9 +449,10 @@ describe("Playlists — removing a row, and undoing it", () => {
|
||||
});
|
||||
|
||||
it("puts it back where it stood in the stored order, whatever the view on screen", async () => {
|
||||
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
|
||||
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
|
||||
serverRemoves(lists);
|
||||
vi.spyOn(api, "addToPlaylist").mockResolvedValue(undefined as never);
|
||||
mount({ 1: [vid("A", 0), vid("B", 0), vid("C", 0)] });
|
||||
mount(lists);
|
||||
await screen.findByRole("button", { name: "card.play — Video C" });
|
||||
// Sort the VIEW by title, descending: C, B, A on screen — the stored order is still A, B, C.
|
||||
fireEvent.click(screen.getByRole("button", { name: /playlists\.colTitle/ }));
|
||||
@@ -463,10 +464,25 @@ describe("Playlists — removing a row, and undoing it", () => {
|
||||
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "A", 0));
|
||||
});
|
||||
|
||||
// E28 review C4: the stored index drifts when the list changes before the Undo (a row added at
|
||||
// the top in another tab) — the row goes back after its old neighbour instead.
|
||||
it("puts it back after its neighbour when the list changed before the Undo", async () => {
|
||||
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
|
||||
serverRemoves(lists);
|
||||
vi.spyOn(api, "addToPlaylist").mockResolvedValue(undefined as never);
|
||||
mount(lists);
|
||||
await rowMenu("Video B", "playlists.removeItem");
|
||||
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
|
||||
lists[1] = [vid("X", 0), ...lists[1]!];
|
||||
act(() => lastToast().action!.onClick());
|
||||
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "B", 2));
|
||||
});
|
||||
|
||||
it("says so when the Undo fails", async () => {
|
||||
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
|
||||
const lists = { 1: [vid("A", 0), vid("B", 0)] };
|
||||
serverRemoves(lists);
|
||||
vi.spyOn(api, "addToPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
mount({ 1: [vid("A", 0), vid("B", 0)] });
|
||||
mount(lists);
|
||||
await rowMenu("Video A", "playlists.removeItem");
|
||||
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
|
||||
act(() => lastToast().action!.onClick());
|
||||
|
||||
@@ -24,6 +24,7 @@ import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { reinsertAt } from "../lib/reinsert";
|
||||
import { useUndoable } from "../lib/useUndoable";
|
||||
import { cycleSort, type SortState } from "../lib/columnSort";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
@@ -476,9 +477,10 @@ export default function Playlists() {
|
||||
// so the guard reset the order: the removed row came back and the undo history the rebase had
|
||||
// kept went with it. (A failure resyncs below, which puts the row back where it belongs.)
|
||||
const before = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
|
||||
// Where it stood in the STORED order — what the Undo puts it back at (the server's list, so a
|
||||
// view sort on screen does not decide it).
|
||||
const at = before?.items.findIndex((v) => v.id === video.id) ?? -1;
|
||||
// The STORED order it stood in — the Undo puts it back after its neighbour there (the server's
|
||||
// list, so a view sort on screen does not decide it; `reinsertAt`, so a change in between does
|
||||
// not shift it off by the rows added or moved meanwhile).
|
||||
const storedBefore = before?.items.map((v) => v.id) ?? [];
|
||||
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
|
||||
old ? { ...old, items: old.items.filter((v) => v.id !== video.id) } : old,
|
||||
);
|
||||
@@ -503,10 +505,23 @@ export default function Playlists() {
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
// ONE request that puts it back at its index (the server renumbers around it): an append
|
||||
// plus a whole-list reorder would land it at the end whenever the second call failed.
|
||||
// plus a whole-list reorder would land it at the end whenever the second call failed. The
|
||||
// index is worked out against the list as it is NOW, fetched fresh.
|
||||
onClick: () =>
|
||||
void api
|
||||
.addToPlaylist(listId, video.id, at < 0 ? undefined : at)
|
||||
void qc
|
||||
.fetchQuery({
|
||||
queryKey: qk.playlist(listId),
|
||||
queryFn: () => api.playlist(listId),
|
||||
staleTime: 0,
|
||||
})
|
||||
.then((now) => {
|
||||
const back = reinsertAt(
|
||||
storedBefore,
|
||||
[video.id],
|
||||
now.items.map((v) => v.id),
|
||||
);
|
||||
if (back.keys.length) return api.addToPlaylist(listId, video.id, back.positions[0]);
|
||||
})
|
||||
.catch((e: unknown) => notifyActionError(e, t("playlists.undoRemoveFailed")))
|
||||
.finally(() => {
|
||||
refreshAll(listId);
|
||||
|
||||
@@ -152,14 +152,22 @@ describe("PlexPlaylistView · a row's menu", () => {
|
||||
it("removes a title with no confirm, and the toast's Undo puts it back at its index", async () => {
|
||||
mount([movie("1"), movie("2"), movie("3")]);
|
||||
await screen.findByText("Movie 2");
|
||||
vi.spyOn(api, "plexPlaylistRemoveItem").mockResolvedValue({} as never);
|
||||
vi.spyOn(api, "plexPlaylistRemoveItem").mockImplementation(async () => {
|
||||
// The server's side of it: the Undo reads the list fresh, and the row is gone from it.
|
||||
vi.mocked(api.plexPlaylist).mockResolvedValue({
|
||||
id: 5,
|
||||
title: "Films",
|
||||
items: [movie("1"), movie("3")],
|
||||
} as never);
|
||||
return {} as never;
|
||||
});
|
||||
const add = vi.spyOn(api, "plexPlaylistAddBulk").mockResolvedValue({} as never);
|
||||
await pick(() => fireEvent.contextMenu(rowOf("Movie 2")), "plex.playlist.removeFromPlaylist");
|
||||
await waitFor(() => expect(api.plexPlaylistRemoveItem).toHaveBeenCalledWith(5, "2"));
|
||||
expect(h.confirm).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(toast()?.message).toBe("plex.playlist.removedToast:Movie 2"));
|
||||
act(() => toast()!.action!.onClick());
|
||||
expect(add).toHaveBeenCalledWith(5, ["2"], [1]);
|
||||
await waitFor(() => expect(add).toHaveBeenCalledWith(5, ["2"], [1]));
|
||||
});
|
||||
|
||||
it("plays and removes a whole show from its group's menu", async () => {
|
||||
@@ -313,3 +321,24 @@ describe("PlexPlaylistView · the header's '…'", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// E28 review E2: the watched toggle flips the view's LOCAL copy of the rows. On a failure the
|
||||
// resync refetches an identical list, react-query keeps the same `data`, and the copy was never
|
||||
// re-seeded — the row stayed "watched" on screen while Plex still had it unwatched.
|
||||
describe("PlexPlaylistView · a failed watched toggle", () => {
|
||||
it("puts the row's watched state back", async () => {
|
||||
mount([movie("1"), movie("2")]);
|
||||
await screen.findByText("Movie 2");
|
||||
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
|
||||
await pick(() => fireEvent.contextMenu(rowOf("Movie 2")), "plex.markWatched");
|
||||
await waitFor(() =>
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error", title: "plex.watchedFailed" }),
|
||||
),
|
||||
);
|
||||
await act(async () => {});
|
||||
fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" });
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Movie 2")));
|
||||
expect(menuRows()).toContain("plex.markWatched");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -560,6 +560,9 @@ export default function PlexPlaylistView({
|
||||
onToggleWatched: (c) =>
|
||||
void toggleWatchState(c, t, {
|
||||
optimistic: (next) => setStatus(c.id, next),
|
||||
// The flip is on the view's LOCAL copy: a failed write's resync refetches an identical
|
||||
// list, react-query keeps the same `data`, and the copy is never re-seeded from it.
|
||||
revert: () => setStatus(c.id, c.status ?? "new"),
|
||||
resync: invalidate,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -129,9 +129,12 @@ describe("usePlexPlaylistEdits · the removal", () => {
|
||||
|
||||
// E28 C1: no confirm — the toast's Undo is the way back, and it puts each row where it stood.
|
||||
it("says what went, and its Undo puts the rows back at their own indices in one request", async () => {
|
||||
const { hook } = setup([movie("1"), movie("2"), movie("3"), movie("4")]);
|
||||
const { hook, detail } = setup([movie("1"), movie("2"), movie("3"), movie("4")]);
|
||||
await waitFor(() => expect(hook.result.current.items).toHaveLength(4));
|
||||
vi.spyOn(api, "plexPlaylistRemoveBulk").mockResolvedValue({} as never);
|
||||
vi.spyOn(api, "plexPlaylistRemoveBulk").mockImplementation(async () => {
|
||||
detail.mockResolvedValue({ id: 5, title: "F", items: [movie("1"), movie("3")] } as never);
|
||||
return {} as never;
|
||||
});
|
||||
const add = vi.spyOn(api, "plexPlaylistAddBulk").mockResolvedValue({} as never);
|
||||
|
||||
// A season whose episodes do not sit together: rows 2 and 4.
|
||||
@@ -140,14 +143,35 @@ describe("usePlexPlaylistEdits · the removal", () => {
|
||||
expect(toast.message).toBe("plex.playlist.removedToast");
|
||||
expect(toast.action!.label).toBe("common.undo");
|
||||
act(() => toast.action!.onClick());
|
||||
expect(add).toHaveBeenCalledTimes(1);
|
||||
expect(add).toHaveBeenCalledWith(5, ["4", "2"], [3, 1]);
|
||||
await waitFor(() => expect(add).toHaveBeenCalledTimes(1));
|
||||
expect(add).toHaveBeenCalledWith(5, ["2", "4"], [1, 3]);
|
||||
});
|
||||
|
||||
// E28 review C4: a raw index drifts when the list changes before the Undo — another tab added a
|
||||
// row at the top — and the row came back one place off. It goes back after its old neighbour.
|
||||
it("puts a row back after its neighbour when the list changed before the Undo", async () => {
|
||||
const { hook, detail } = setup([movie("1"), movie("2"), movie("3")]);
|
||||
await waitFor(() => expect(hook.result.current.items).toHaveLength(3));
|
||||
vi.spyOn(api, "plexPlaylistRemoveItem").mockResolvedValue({} as never);
|
||||
const add = vi.spyOn(api, "plexPlaylistAddBulk").mockResolvedValue({} as never);
|
||||
await act(() => hook.result.current.onRemove(["2"], "Movie 2"));
|
||||
detail.mockResolvedValue({
|
||||
id: 5,
|
||||
title: "F",
|
||||
items: [movie("x"), movie("1"), movie("3")],
|
||||
} as never);
|
||||
const toast = notify.mock.calls.map(([n]) => n).find((n) => n.action)!;
|
||||
act(() => toast.action!.onClick());
|
||||
await waitFor(() => expect(add).toHaveBeenCalledWith(5, ["2"], [2]));
|
||||
});
|
||||
|
||||
it("says so when the Undo fails", async () => {
|
||||
const { hook } = setup([movie("1"), movie("2")]);
|
||||
const { hook, detail } = setup([movie("1"), movie("2")]);
|
||||
await waitFor(() => expect(hook.result.current.items).toHaveLength(2));
|
||||
vi.spyOn(api, "plexPlaylistRemoveItem").mockResolvedValue({} as never);
|
||||
vi.spyOn(api, "plexPlaylistRemoveItem").mockImplementation(async () => {
|
||||
detail.mockResolvedValue({ id: 5, title: "F", items: [movie("2")] } as never);
|
||||
return {} as never;
|
||||
});
|
||||
vi.spyOn(api, "plexPlaylistAddBulk").mockRejectedValue(new Error("offline"));
|
||||
await act(() => hook.result.current.onRemove(["1"], "Movie 1"));
|
||||
const toast = notify.mock.calls.map(([n]) => n).find((n) => n.action)!;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api, type PlexCard } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { reinsertAt } from "../lib/reinsert";
|
||||
|
||||
/** A Plex playlist's items and the two writes that edit them in place — a reorder and a removal.
|
||||
* Split from `PlexPlaylistView` so the failure paths are reachable from a test: the reorder is
|
||||
@@ -81,14 +82,15 @@ export function usePlexPlaylistEdits(playlistId: number) {
|
||||
}
|
||||
|
||||
/** Take `keys` out — one row, a season or a whole show — with no confirm: the toast's Undo puts
|
||||
* each row back at the index it held (E28 C1, the same bargain as a YouTube playlist's rows).
|
||||
* `title` names what went in that toast. */
|
||||
* each row back where it stood (E28 C1, the same bargain as a YouTube playlist's rows) — after
|
||||
* its old neighbour in the list as it is at Undo time (`reinsertAt`), since a raw index drifts
|
||||
* once anything changes in between. `title` names what went in that toast. */
|
||||
async function onRemove(keys: string[], title: string) {
|
||||
if (busy || !keys.length) return;
|
||||
setBusy(true);
|
||||
// Indices over the list as it stands now, which is the server's order: a season's episodes need
|
||||
// not sit together (a reorder inside the show interleaves them), so each key keeps its own.
|
||||
const positions = keys.map((k) => items.findIndex((i) => i.id === k));
|
||||
// The list as it stands now, which is the server's order: a season's episodes need not sit
|
||||
// together (a reorder inside the show interleaves them), so each key keeps its own neighbour.
|
||||
const before = items.map((i) => i.id);
|
||||
const set = new Set(keys);
|
||||
setItems((prev) => prev.filter((i) => !set.has(i.id)));
|
||||
try {
|
||||
@@ -102,19 +104,27 @@ export function usePlexPlaylistEdits(playlistId: number) {
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
const back = keys.filter((_, i) => positions[i]! >= 0);
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("plex.playlist.removedToast", { title }),
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
onClick: () =>
|
||||
void api
|
||||
.plexPlaylistAddBulk(
|
||||
playlistId,
|
||||
back,
|
||||
positions.filter((p) => p >= 0),
|
||||
)
|
||||
void qc
|
||||
.fetchQuery({
|
||||
queryKey: qk.plexPlaylist(playlistId),
|
||||
queryFn: () => api.plexPlaylist(playlistId),
|
||||
staleTime: 0,
|
||||
})
|
||||
.then((now) => {
|
||||
const back = reinsertAt(
|
||||
before,
|
||||
keys,
|
||||
now.items.map((i) => i.id),
|
||||
);
|
||||
if (back.keys.length)
|
||||
return api.plexPlaylistAddBulk(playlistId, back.keys, back.positions);
|
||||
})
|
||||
.catch((e: unknown) => notifyActionError(e, t("plex.playlist.undoRemoveFailed")))
|
||||
.finally(invalidate),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user