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