feat(playlists): drag rows onto a list in the rail

A row's grip carries it, or every selected row with a selected one, onto another
list, under a sort or a filter too, and the toast undoes. The rail and the page
are separate React trees, so the page reads the rail by what lies under the
pointer. An unpinned rail peeks by the drag's own hover and scrolls at its edges.
Many rows dropped inside the list stay; a key still moves its own row.
This commit is contained in:
2026-11-02 00:38:39 +01:00
parent 9b76d2f96c
commit d79cb528fd
12 changed files with 1228 additions and 13 deletions
+259
View File
@@ -0,0 +1,259 @@
import { test, expect, type Locator, type Page } from "@playwright/test";
import { openApp } from "./helpers";
// A video dragged onto a list in the rail, with a real pointer: the rail and the page are separate
// React trees, and jsdom lays out nothing to drag across. The browser projects share one account, so
// each test holds lists of its own, named for it, and takes them away after.
interface Listed {
id: number;
items: { id: string; title: string }[];
}
const made: number[] = [];
async function newList(page: Page, name: string, videos: string[] = []): Promise<number> {
const res = await page.request.post("/api/playlists", { data: { name } });
const { id } = (await res.json()) as { id: number };
made.push(id);
for (const video_id of videos) {
const added = await page.request.post(`/api/playlists/${String(id)}/items`, {
data: { video_id },
});
expect(added.ok()).toBe(true);
}
return id;
}
const contents = async (page: Page, id: number) =>
((await (await page.request.get(`/api/playlists/${String(id)}`)).json()) as Listed).items.map(
(v) => v.id,
);
async function setUp(page: Page, name: string, project: string) {
await pinOnTouch(page, project);
const feed = (await (await page.request.get("/api/feed?limit=3")).json()) as {
items: { id: string; title: string }[];
};
expect(feed.items.length).toBe(3);
const videos = feed.items;
const from = await newList(
page,
`${name} from`,
videos.map((v) => v.id),
);
const to = await newList(page, `${name} to`);
await openApp(page, `/?page=playlists&pl=${String(from)}`);
await expect(page.getByRole("heading", { name: `${name} from` })).toBeVisible();
await expect(page.locator("tbody tr")).toHaveCount(3);
return { videos, from, to, target: page.locator(`[data-playlist-drop="${String(to)}"]`) };
}
const centre = async (el: Locator) => {
const box = await el.boundingBox();
if (!box) throw new Error("nothing to point at");
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
};
/** Pick the row up by its grip and carry it to a point, the button still held. */
async function carry(page: Page, grip: Locator, to: { x: number; y: number }) {
const from = await centre(grip);
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(from.x + 8, from.y + 8, { steps: 3 });
await page.mouse.move(to.x, to.y, { steps: 12 });
}
/** The same onto the rail. An unpinned one is a tab until the drag's own hover peeks it open. */
async function carryToRail(page: Page, grip: Locator, target: Locator) {
if (await target.isVisible()) {
await carry(page, grip, await centre(target));
return;
}
const tab = page.getByRole("button", { name: "Pin Playlists open" });
await carry(page, grip, await centre(tab));
await expect(target).toBeVisible();
await page.mouse.move(...(Object.values(await centre(target)) as [number, number]), { steps: 6 });
}
/** What dnd-kit tells a screen reader: said once the drag has taken the step in, so a drop after it
* is the one a person makes, not one that beats the row to its new place. */
const said = (page: Page, text: RegExp) =>
expect(page.locator('[id^="DndLiveRegion"]').filter({ hasText: text })).toBeAttached();
/** A coarse pointer's rail never peeks, a drawer instead: on the tablet it is pinned, for these. */
async function pinOnTouch(page: Page, project: string) {
if (project !== "ipad") return;
await page.route("**/api/me", async (route) => {
const res = await route.fetch();
const me = (await res.json()) as { preferences?: Record<string, unknown> };
await route.fulfill({
response: res,
json: { ...me, preferences: { ...me.preferences, playlistsCollapsed: false } },
});
});
}
test.describe("dragging onto a list", { tag: "@playlists" }, () => {
test.afterEach(async ({ page }) => {
await page.mouse.up();
for (const id of made.splice(0)) await page.request.delete(`/api/playlists/${String(id)}`);
});
test("a row leaves for the list it is dropped on, lit up on the way, and Undo brings it back", async ({
page,
}, info) => {
const { videos, from, to, target } = await setUp(
page,
`drag one ${info.project.name}`,
info.project.name,
);
const grip = page.getByRole("button", { name: `Reorder “${videos[1]!.title}”`, exact: false });
await carryToRail(page, grip, target);
await expect(target).toHaveClass(/ring-2/);
await page.mouse.up();
await expect(target).not.toHaveClass(/ring-2/);
await expect(page.locator("tbody tr")).toHaveCount(2);
// The grip that held the focus has left: the keyboard keeps its place in the list.
await expect
.poll(() => page.evaluate(() => document.activeElement?.closest("tbody") != null))
.toBe(true);
await expect.poll(() => contents(page, to)).toEqual([videos[1]!.id]);
expect(await contents(page, from)).toEqual([videos[0]!.id, videos[2]!.id]);
await page.getByRole("button", { name: "Undo", exact: true }).last().click();
await expect.poll(() => contents(page, from)).toEqual(videos.map((v) => v.id));
expect(await contents(page, to)).toEqual([]);
});
test("a selected row carries the selected ones, named on the drag image", async ({
page,
}, info) => {
const { videos, from, to, target } = await setUp(
page,
`drag many ${info.project.name}`,
info.project.name,
);
await page.getByRole("checkbox", { name: videos[0]!.title, exact: false }).click();
await page.getByRole("checkbox", { name: videos[2]!.title, exact: false }).click();
const grip = page.getByRole("button", { name: `Reorder “${videos[2]!.title}”`, exact: false });
await carryToRail(page, grip, target);
const image = page.getByText("2 videos", { exact: true });
await expect(image).toBeVisible();
// Below the pointer, so it leaves the list's name to read.
const { y } = await centre(target);
expect((await image.boundingBox())!.y).toBeGreaterThan(y);
await page.mouse.up();
await expect.poll(() => contents(page, to)).toEqual([videos[0]!.id, videos[2]!.id]);
expect(await contents(page, from)).toEqual([videos[1]!.id]);
await expect(page.getByRole("checkbox", { name: "Select all" })).not.toBeChecked();
});
test("many rows dropped inside the list stay as they were", async ({ page }, info) => {
const { videos, from } = await setUp(
page,
`drag inside ${info.project.name}`,
info.project.name,
);
await page.getByRole("checkbox", { name: videos[0]!.title, exact: false }).click();
await page.getByRole("checkbox", { name: videos[1]!.title, exact: false }).click();
const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false });
await carry(page, grip, await centre(page.locator("tbody tr").nth(2)));
await page.mouse.up();
await expect(page.locator("tbody tr")).toHaveCount(3);
expect(await contents(page, from)).toEqual(videos.map((v) => v.id));
});
test("one row dropped inside the list takes the new place", async ({ page }, info) => {
const { videos, from } = await setUp(
page,
`drag order ${info.project.name}`,
info.project.name,
);
const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false });
await carry(page, grip, await centre(page.locator("tbody tr").nth(2)));
await said(page, /position 3/);
await page.mouse.up();
await expect
.poll(() => contents(page, from))
.toEqual([videos[1]!.id, videos[2]!.id, videos[0]!.id]);
});
test("Escape puts the drag down, with nothing moved and nothing lit", async ({ page }, info) => {
const { videos, from, to, target } = await setUp(
page,
`drag cancel ${info.project.name}`,
info.project.name,
);
const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false });
await carryToRail(page, grip, target);
await expect(target).toHaveClass(/ring-2/);
await page.keyboard.press("Escape");
await expect(target).not.toHaveClass(/ring-2/);
await page.mouse.up();
expect(await contents(page, from)).toEqual(videos.map((v) => v.id));
expect(await contents(page, to)).toEqual([]);
});
test("a key still reorders a row, alone", async ({ page }, info) => {
const { videos, from } = await setUp(page, `drag key ${info.project.name}`, info.project.name);
await page.getByRole("checkbox", { name: videos[0]!.title, exact: false }).click();
await page.getByRole("checkbox", { name: videos[2]!.title, exact: false }).click();
await page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false }).focus();
await page.keyboard.press("Space");
await said(page, /picked up/i);
await page.keyboard.press("ArrowDown");
await said(page, /position 2/);
await page.keyboard.press("Space");
await expect
.poll(() => contents(page, from))
.toEqual([videos[1]!.id, videos[0]!.id, videos[2]!.id]);
});
test("under a filter a row still goes to another list, and none is reordered", async ({
page,
}, info) => {
const { videos, from, to, target } = await setUp(
page,
`drag filter ${info.project.name}`,
info.project.name,
);
await page.getByRole("columnheader", { name: "Title" }).getByRole("button").last().click();
await page.getByPlaceholder("Title").fill(videos[1]!.title);
await page.keyboard.press("Escape");
await expect(page.locator("tbody tr")).toHaveCount(1);
const grip = page.getByRole("button", {
name: `Drag “${videos[1]!.title}” onto a list`,
exact: false,
});
await carryToRail(page, grip, target);
await page.mouse.up();
await expect.poll(() => contents(page, to)).toEqual([videos[1]!.id]);
expect(await contents(page, from)).toEqual([videos[0]!.id, videos[2]!.id]);
});
test("the rail scrolls while the drag holds at its edge", async ({ page }, info) => {
const name = `drag scroll ${info.project.name}`;
for (let i = 0; i < 12; i++) await newList(page, `${name} ${String(i)}`);
await page.setViewportSize({ width: 1280, height: 520 });
const { videos } = await setUp(page, name, info.project.name);
const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false });
const area = page.locator("[data-playlist-drop-area]");
await carryToRail(page, grip, area.locator("[data-playlist-drop]").first());
const scroller = () =>
area.evaluate((el) => {
for (let p = el.parentElement; p; p = p.parentElement) {
const { overflowY } = getComputedStyle(p);
if ((overflowY === "auto" || overflowY === "scroll") && p.scrollHeight > p.clientHeight) {
const { left, right, bottom } = p.getBoundingClientRect();
return { top: p.scrollTop, x: (left + right) / 2, bottom };
}
}
return { top: -1, x: 0, bottom: 0 };
});
const before = await scroller();
expect(before.top).toBeGreaterThanOrEqual(0);
await page.mouse.move(before.x, before.bottom - 8, { steps: 4 });
await expect.poll(async () => (await scroller()).top).toBeGreaterThan(before.top);
await page.keyboard.press("Escape");
});
});