An Esc during a pointer drag is spent on the drag and leaves the selection. A wheel over the rail re-reads the target each frame, so a drop lands on the row under the pointer. After a bulk removal the focus goes to the next row left, and to the list's menu when the list is emptied. remove-bulk names the videos that went, and its Undo brings back only those. An Undo of a move keeps each row (and when it was saved), leaves out a video that moved on from the target, closes up its place, and says how many came back. Delete with every tick hidden removes nothing and says why. A list gone before the lock answers 404, and a body without its videos 422. One removal path now: the single row goes through the bulk one, the same toast and undo, which also retires the add-at-position route. railDrop reuses the store and scrollParent, the drag image the overlay and the z-index token, and the sensors keep their number. The count is read out from a region that outlives the band. Tests fill the gaps the review named, each seen failing.
313 lines
14 KiB
TypeScript
313 lines
14 KiB
TypeScript
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("acting on the selected rows", { tag: "@playlists" }, () => {
|
|
test.afterEach(async ({ page }) => {
|
|
for (const id of made.splice(0)) await page.request.delete(`/api/playlists/${String(id)}`);
|
|
});
|
|
|
|
const box = (page: Page, title: string) =>
|
|
page.getByRole("checkbox", { name: `Select “${title}”` });
|
|
|
|
test("a Shift range, the band's Remove, and its Undo back to the places", async ({
|
|
page,
|
|
}, info) => {
|
|
const { videos, from } = await setUp(page, `band ${info.project.name}`, info.project.name);
|
|
await box(page, videos[0]!.title).click();
|
|
await box(page, videos[1]!.title).click({ modifiers: ["Shift"] });
|
|
await expect(box(page, videos[1]!.title)).toBeChecked();
|
|
await expect(box(page, videos[2]!.title)).not.toBeChecked();
|
|
await page.getByRole("button", { name: "Remove", exact: true }).click();
|
|
await expect(page.locator("tbody tr")).toHaveCount(1);
|
|
await expect.poll(() => contents(page, from)).toEqual([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));
|
|
await expect(page.locator("tbody tr")).toHaveCount(3);
|
|
});
|
|
|
|
test("x ticks the walked row, Esc clears, and Delete takes the selection and goes on", async ({
|
|
page,
|
|
}, info) => {
|
|
const { videos, from } = await setUp(page, `keys ${info.project.name}`, info.project.name);
|
|
const firstRow = page.locator("tbody tr").first();
|
|
await firstRow.focus();
|
|
await page.keyboard.press("x");
|
|
await expect(box(page, videos[0]!.title)).toBeChecked();
|
|
await page.keyboard.press("Escape");
|
|
await expect(box(page, videos[0]!.title)).not.toBeChecked();
|
|
await page.keyboard.press("x");
|
|
await page.keyboard.press("j");
|
|
await page.keyboard.press("x");
|
|
await page.keyboard.press("Delete");
|
|
await expect.poll(() => contents(page, from)).toEqual([videos[2]!.id]);
|
|
await expect(page.locator("tbody tr")).toHaveCount(1);
|
|
await expect(firstRow).toBeFocused();
|
|
});
|
|
});
|
|
|
|
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();
|
|
// Above every layer, by the tooltip's token: the overlay wrapping it takes the level.
|
|
expect(await image.evaluate((el) => getComputedStyle(el.parentElement!).zIndex)).toBe("100");
|
|
// 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,
|
|
);
|
|
// A tick on another row: the Esc is the drag's, and leaves the selection alone.
|
|
const ticked = page.getByRole("checkbox", { name: `Select “${videos[2]!.title}”` });
|
|
await ticked.click();
|
|
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([]);
|
|
await expect(ticked).toBeChecked();
|
|
});
|
|
|
|
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);
|
|
// A person's pause: dnd-kit drops an arrow pressed before it has measured the lifted row, which
|
|
// nothing on the page announces (11 of 31 runs without it, none of 31 with it).
|
|
await page.waitForTimeout(300);
|
|
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");
|
|
});
|
|
});
|