feat(design): a playlist's one action list (C1) — the row's "…", Delete + undo, the header's "…" (E28 S3e2)

- Playlist rows: the X column became the row's "…" (play, pin, copy title/link, move, order,
  remove). Remove runs at once, with no confirm; its toast's Undo puts the row back at its index.
  The Delete key removes a keyboard-walked row, and focus moves on to the next.
- POST /api/playlists/{id}/items takes an optional `position`, so an undo is one request that
  renumbers the list around the row.
- A new `listDelete` key scope. Only a list with `onDelete` registers it, so the help sheet does not
  offer Delete where nothing handles it.
- A playlist has one list, `usePlaylistActions.items`: rename, pin, YouTube open/push/revert,
  delete. The rail row's "…" and the page header's "…" both open it. Push and revert moved into
  the hook; the header keeps Play all.
- Phone-width playlist table: "#" and Channel are hidden below sm (`Column.phoneHidden`). Their
  fixed widths had left the title no width.
This commit is contained in:
2026-09-26 04:05:39 +02:00
parent aaf1843b09
commit 7455fff401
24 changed files with 1061 additions and 425 deletions
+181 -2
View File
@@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const h = vi.hoisted(() => ({
notify: vi.fn(),
confirm: vi.fn(async () => true),
selectedId: 1 as number | null,
mounted: false,
mounts: 0,
@@ -35,8 +36,18 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
notify: (...a: unknown[]) => h.notify(...a),
}));
vi.mock("./playlistsContext", () => ({ usePlaylists: () => ({ selectedId: h.selectedId }) }));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./usePlaylistActions", () => ({ usePlaylistActions: () => ({ remove: vi.fn() }) }));
vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
// The list's own menu is usePlaylistActions' (pinned in its own test); here only its rename row,
// which opens THIS page's title field.
vi.mock("./usePlaylistActions", () => ({
usePlaylistActions: () => ({
remove: vi.fn(),
rename: vi.fn(),
items: (_pl: unknown, o: { onRename: () => void }) => [
{ id: "rename", label: "playlists.rename", onSelect: o.onRename },
],
}),
}));
vi.mock("./PlayerModal", async () => {
const { useEffect } = await import("react");
return {
@@ -131,6 +142,7 @@ beforeEach(() => {
h.mounted = false;
h.mounts = 0;
h.notify.mockClear();
h.confirm.mockClear();
});
afterEach(() => {
cleanup();
@@ -393,6 +405,173 @@ describe("Playlists — a change the server refused is taken back on screen", ()
});
});
// E28 C1: a row's X column went. Remove is in the row's menu and on the Delete key, with no confirm —
// the toast's Undo puts the row back where it stood, in ONE request.
describe("Playlists — removing a row, and undoing it", () => {
// The server's side of a removal, so the refetch after it agrees with it.
const serverRemoves = (lists: Record<number, Video[]>) =>
vi.spyOn(api, "removeFromPlaylist").mockImplementation(async (id: number, videoId: string) => {
lists[id] = lists[id]!.filter((v) => v.id !== videoId);
return undefined as never;
});
// The removal's toast — found by what it says, not by being the latest: a refetch after the
// removal may notify too.
const lastToast = () =>
h.notify.mock.calls
.map((c) => c[0] as { message: string; action?: { label: string; onClick: () => void } })
.filter((n) => n.message?.startsWith("playlists.removedToast"))
.pop()!;
const pressKey = (key: string) =>
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
it("removes from the menu at once, and the toast's Undo puts it back at its place", 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(api.removeFromPlaylist).toHaveBeenCalledWith(1, "B"));
expect(rowsOnScreen()).toEqual(["Video A", "Video C"]);
// No confirm: the toast's Undo is the safety net.
expect(h.confirm).not.toHaveBeenCalled();
await waitFor(() => expect(lastToast().message).toBe("playlists.removedToast:Video B"));
expect(lastToast().action?.label).toBe("common.undo");
act(() => lastToast().action!.onClick());
// Index 1 of the STORED order: where it was, not the end of the list.
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "B", 1));
});
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);
vi.spyOn(api, "addToPlaylist").mockResolvedValue(undefined as never);
mount({ 1: [vid("A", 0), vid("B", 0), vid("C", 0)] });
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/ }));
fireEvent.click(screen.getByRole("button", { name: /playlists\.colTitle/ }));
await waitFor(() => expect(rowsOnScreen()).toEqual(["Video C", "Video B", "Video A"]));
await rowMenu("Video A", "playlists.removeItem");
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
act(() => lastToast().action!.onClick());
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "A", 0));
});
it("says so when the Undo fails", async () => {
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
vi.spyOn(api, "addToPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
mount({ 1: [vid("A", 0), vid("B", 0)] });
await rowMenu("Video A", "playlists.removeItem");
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
act(() => lastToast().action!.onClick());
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "playlists.undoRemoveFailed" }),
),
);
});
it("removes the walked row on Delete, and the focus goes on to the next", async () => {
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
serverRemoves(lists);
mount(lists);
await screen.findByRole("button", { name: "card.play — Video C" });
pressKey("j");
pressKey("j");
expect(document.activeElement?.textContent).toContain("Video B");
pressKey("Delete");
await waitFor(() => expect(api.removeFromPlaylist).toHaveBeenCalledWith(1, "B"));
expect(rowsOnScreen()).toEqual(["Video A", "Video C"]);
await waitFor(() => expect(document.activeElement?.textContent).toContain("Video C"));
});
it("offers no Delete, no Remove and no X on Watch later's rows", async () => {
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
h.selectedId = WATCH_LATER;
mount({ [WATCH_LATER]: [vid("A", 0), vid("B", 0)] });
await screen.findByRole("button", { name: "card.play — Video B" });
pressKey("j");
pressKey("Delete");
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(api.removeFromPlaylist).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: /removeItemOf/ })).toBeNull();
});
it("opens the row's menu from its '…', with the copy rows", async () => {
mount({ 1: [vid("A", 0)] });
await screen.findByRole("button", { name: "card.play — Video A" });
const more = screen.getByRole("button", { name: "playlists.rowMenu" });
expect(more.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(more);
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent)).toEqual(
expect.arrayContaining([
"card.play",
"common.copy.title",
"common.copy.link",
"playlists.removeItem",
]),
),
);
expect(
screen.getByRole("button", { name: "playlists.rowMenu" }).getAttribute("aria-expanded"),
).toBe("true");
});
});
// E28 C1: the header keeps Play all; rename, YouTube and delete are the list's menu, behind a "…".
describe("Playlists · the header's '…'", () => {
it("replaces the pencil, the YouTube buttons and the trash, and renames from its menu", async () => {
mount({ 1: [vid("A", 0)] });
await screen.findByRole("button", { name: "card.play — Video A" });
for (const gone of [
"playlists.rename",
"playlists.delete",
"playlists.pushToYoutube",
"playlists.exportToYoutube",
"playlists.openOnYoutube",
])
expect(screen.queryByRole("button", { name: gone })).toBeNull();
const more = screen.getByRole("button", { name: "playlists.railMenu" });
act(() => more.focus());
fireEvent.click(more);
// The surface is visibility:hidden in jsdom (floating-ui never positions): matched on text.
const renameRow = await waitFor(() => {
const row = screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "playlists.rename");
expect(row).toBeTruthy();
return row!;
});
fireEvent.click(renameRow);
const field = await screen.findByRole("textbox", { name: "playlists.rename" });
// Leaving the field by Escape hands the focus back to the "…" it was opened from.
fireEvent.keyDown(field, { key: "Escape" });
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "playlists.railMenu" }),
),
);
});
});
describe("Playlists · a phone-width table", () => {
it("leaves out '#' and Channel below sm, header and cells alike, so the title keeps a width", async () => {
mount({ 1: [vid("A", 0)] });
await screen.findByRole("button", { name: "card.play — Video A" });
const phoneHidden = (el: Element) =>
el.classList.contains("hidden") && el.classList.contains("sm:table-cell");
const heads = [...document.querySelectorAll("thead th")];
const cells = [...document.querySelectorAll("tbody tr:first-child td")];
// Columns: grip, #, title, channel, length, "…".
expect(heads.map(phoneHidden)).toEqual([false, true, false, true, false, false]);
expect(cells.map(phoneHidden)).toEqual([false, true, false, true, false, false]);
});
});
describe("Playlists — the rows are a keyboard list", () => {
it("walks the rows with j and plays the focused one on Enter", async () => {
mount({ 1: [vid("A", 0), vid("B", 0)] });