fix(playlists): what the first review of bulk actions found
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.
This commit is contained in:
@@ -94,6 +94,50 @@ async function pinOnTouch(page: Page, project: string) {
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -139,6 +183,8 @@ test.describe("dragging onto a list", { tag: "@playlists" }, () => {
|
||||
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);
|
||||
@@ -184,6 +230,9 @@ test.describe("dragging onto a list", { tag: "@playlists" }, () => {
|
||||
`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/);
|
||||
@@ -192,6 +241,7 @@ test.describe("dragging onto a list", { tag: "@playlists" }, () => {
|
||||
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) => {
|
||||
|
||||
@@ -94,10 +94,13 @@ describe("videoWrites", () => {
|
||||
});
|
||||
|
||||
it("marks a bulk removal from Watch later, and its restore", async () => {
|
||||
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({ ok: true } as never);
|
||||
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({
|
||||
ok: true,
|
||||
removed: ["X"],
|
||||
} as never);
|
||||
vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true } as never);
|
||||
const qc = new QueryClient();
|
||||
await removePlaylistItems(qc, WATCH_LATER, ["X"]);
|
||||
await removePlaylistItems(qc, WATCH_LATER, ["X", "gone"]);
|
||||
expect(marks(qc)).toEqual({ X: { saved: false } });
|
||||
await restorePlaylistItems(qc, WATCH_LATER, ["X"], [0]);
|
||||
expect(marks(qc)).toEqual({ X: { saved: true } });
|
||||
|
||||
@@ -43,10 +43,8 @@ const markIfWatchLater = (qc: QueryClient, list: ListRef, id: string, saved: boo
|
||||
if (list.kind === "watch_later") markVideo(qc, id, { saved });
|
||||
};
|
||||
|
||||
/** Add a video to a playlist; `position` (an index in the stored order) puts it back where a
|
||||
* removal took it from. */
|
||||
export function addPlaylistItem(qc: QueryClient, list: ListRef, id: string, position?: number) {
|
||||
return api.addToPlaylist(list.id, id, position).then((r) => {
|
||||
export function addPlaylistItem(qc: QueryClient, list: ListRef, id: string) {
|
||||
return api.addToPlaylist(list.id, id).then((r) => {
|
||||
markIfWatchLater(qc, list, id, true);
|
||||
return r;
|
||||
});
|
||||
@@ -59,10 +57,10 @@ export function removePlaylistItem(qc: QueryClient, list: ListRef, id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Take videos out of a playlist, in one request. */
|
||||
/** Take videos out of a playlist, in one request. Marks what the server says went. */
|
||||
export function removePlaylistItems(qc: QueryClient, list: ListRef, ids: string[]) {
|
||||
return api.removeManyFromPlaylist(list.id, ids).then((r) => {
|
||||
for (const id of ids) markIfWatchLater(qc, list, id, false);
|
||||
for (const id of r.removed) markIfWatchLater(qc, list, id, false);
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"listStep": "Next / previous item",
|
||||
"gridMove": "Move around the list",
|
||||
"open": "Open the current item",
|
||||
"listRemove": "Remove the selected rows, or else the current one",
|
||||
"listRemove": "Remove from the list",
|
||||
"listSelect": "Select or deselect the current item",
|
||||
"listClearSelection": "Clear the selection",
|
||||
"cap": {
|
||||
|
||||
@@ -117,6 +117,15 @@
|
||||
"removedManyToast_other": "{{count}} videos removed from the playlist",
|
||||
"moveNoneLeft": "Nothing moved: they aren’t in this playlist any more",
|
||||
"removeNoneLeft": "Nothing removed: they aren’t in this playlist any more",
|
||||
"selectionCleared": "Selection cleared",
|
||||
"removeAllHidden": "The selected rows are hidden by the filter",
|
||||
"undoNoneBack": "Nothing came back: those videos aren’t where they were any more",
|
||||
"undoSomeBack_one": "Only {{count}} video came back: the rest aren’t where they were any more",
|
||||
"undoSomeBack_other": "Only {{count}} videos came back: the rest aren’t where they were any more",
|
||||
"dragCancelledMany_one": "Move cancelled; {{count}} video stays where it was",
|
||||
"dragCancelledMany_other": "Move cancelled; {{count}} videos stay where they were",
|
||||
"moveTargetsFailed": "Your playlists couldn’t be loaded",
|
||||
"moveTargetsLoading": "Loading your playlists…",
|
||||
"moveManyFailed": "Couldn't move the videos",
|
||||
"removeManyFailed": "Couldn't remove the videos",
|
||||
"undoMoveFailed": "Couldn't move the video back",
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
"listStep": "Következő / előző elem",
|
||||
"gridMove": "Mozgás a listában",
|
||||
"open": "Az aktuális elem megnyitása",
|
||||
"listRemove": "A kijelölt sorok, vagy az aktuális eltávolítása",
|
||||
"listSelect": "Az aktuális elem kijelölése vagy levétele",
|
||||
"listRemove": "Eltávolítás a listából",
|
||||
"listSelect": "Az aktuális elem kijelölése, vagy a kijelölés törlése",
|
||||
"listClearSelection": "A kijelölés törlése",
|
||||
"cap": {
|
||||
"space": "Szóköz",
|
||||
|
||||
@@ -117,6 +117,15 @@
|
||||
"removedManyToast_other": "{{count}} videó eltávolítva a listáról",
|
||||
"moveNoneLeft": "Nem történt áthelyezés: ezek már nincsenek ebben a listában",
|
||||
"removeNoneLeft": "Nem történt eltávolítás: ezek már nincsenek ebben a listában",
|
||||
"selectionCleared": "Kijelölés törölve",
|
||||
"removeAllHidden": "A kijelölt sorokat a szűrő elrejti",
|
||||
"undoNoneBack": "Semmi nem került vissza: ezek a videók már nincsenek ott, ahol voltak",
|
||||
"undoSomeBack_one": "Csak {{count}} videó került vissza: a többi már nincs ott, ahol volt",
|
||||
"undoSomeBack_other": "Csak {{count}} videó került vissza: a többi már nincs ott, ahol volt",
|
||||
"dragCancelledMany_one": "Áthelyezés megszakítva; {{count}} videó a helyén marad",
|
||||
"dragCancelledMany_other": "Áthelyezés megszakítva; {{count}} videó a helyén marad",
|
||||
"moveTargetsFailed": "Nem sikerült betölteni a listáidat",
|
||||
"moveTargetsLoading": "A listáid betöltése…",
|
||||
"moveManyFailed": "Nem sikerült áthelyezni a videókat",
|
||||
"removeManyFailed": "Nem sikerült eltávolítani a videókat",
|
||||
"undoMoveFailed": "Nem sikerült visszahelyezni a videót",
|
||||
|
||||
@@ -70,10 +70,10 @@ export const playlistsApi = {
|
||||
req(`/api/playlists/${id}${onYoutube ? "?on_youtube=true" : ""}`, { method: "DELETE" }),
|
||||
// `position` (an index in the stored order) puts it back where a removal took it from — the
|
||||
// list page's Undo; omitted, it is appended.
|
||||
addToPlaylist: (id: number, videoId: string, position?: number) =>
|
||||
addToPlaylist: (id: number, videoId: string) =>
|
||||
req(`/api/playlists/${id}/items`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ video_id: videoId, ...(position != null && { position }) }),
|
||||
body: JSON.stringify({ video_id: videoId }),
|
||||
}),
|
||||
removeFromPlaylist: (id: number, videoId: string) =>
|
||||
req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }),
|
||||
@@ -115,7 +115,7 @@ export const playlistsApi = {
|
||||
removeManyFromPlaylist: (
|
||||
id: number,
|
||||
videoIds: string[],
|
||||
): Promise<{ ok: true; playlist_id: number; removed: number }> =>
|
||||
): Promise<{ ok: true; playlist_id: number; removed: string[] }> =>
|
||||
req(`/api/playlists/${id}/items/remove-bulk`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ video_ids: videoIds }),
|
||||
|
||||
@@ -13,15 +13,17 @@ afterEach(cleanup);
|
||||
function Rows({
|
||||
selection,
|
||||
onDelete,
|
||||
rows = 3,
|
||||
}: {
|
||||
selection?: NavSelection;
|
||||
onDelete?: (index: number) => void;
|
||||
rows?: number;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useListNav(ref, { enabled: true, selection, onDelete });
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
{Array.from({ length: rows }, (_, i) => (
|
||||
<div key={i} {...navItemAttrs(i)} data-testid={`r${i}`}>
|
||||
<button data-testid={`r${i}-action`}>action</button>
|
||||
<input data-testid={`r${i}-input`} />
|
||||
@@ -125,6 +127,26 @@ describe("useListNav · selecting", () => {
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByTestId("r2")));
|
||||
});
|
||||
|
||||
it("goes on to the next row left, past what a selection took from above it", async () => {
|
||||
const s = selection();
|
||||
s.removeSelected.mockImplementation(() => {
|
||||
// As a commit does it, after the key's handler: the two rows go, and the ones left are numbered
|
||||
// from the top.
|
||||
queueMicrotask(() => {
|
||||
screen.getByTestId("r0").remove();
|
||||
screen.getByTestId("r1").remove();
|
||||
screen.getByTestId("r2").setAttribute("data-nav-index", "0");
|
||||
screen.getByTestId("r3").setAttribute("data-nav-index", "1");
|
||||
});
|
||||
return true;
|
||||
});
|
||||
render(<Rows selection={s} rows={4} />);
|
||||
const r2 = screen.getByTestId("r2");
|
||||
screen.getByTestId("r1-box").focus();
|
||||
press("Delete");
|
||||
await waitFor(() => expect(document.activeElement).toBe(r2));
|
||||
});
|
||||
|
||||
it("leaves the focus where it is when something else took it meanwhile", async () => {
|
||||
render(<Rows />);
|
||||
const leaving = screen.getByTestId("r1");
|
||||
|
||||
@@ -476,10 +476,14 @@ function onKey(e: KeyboardEvent): void {
|
||||
// How long a removal gets to take its row out of the DOM before the focus gives up following it.
|
||||
const REMOVE_FRAMES = 30;
|
||||
|
||||
/** After the focused item was removed, focus the item now in its slot (the next one; the new last
|
||||
* when it was the last), so a second Delete takes the next row — the way a mail list behaves. Only
|
||||
* while nothing else took the focus meanwhile: the removal fell back to <body>, nothing more. */
|
||||
/** After the focused item was removed, focus the item that followed it and is still there (the new
|
||||
* last when none is), so a second Delete takes the next row — the way a mail list behaves. By element:
|
||||
* rows a selection took from above it would shift an index past the ones left. Only while nothing else
|
||||
* took the focus meanwhile: the removal fell back to <body>, nothing more. */
|
||||
function refocusSlot(list: NavList, index: number, removed: HTMLElement): void {
|
||||
const items = ownItems(list.el);
|
||||
const at = items.indexOf(removed);
|
||||
const after = at < 0 ? [] : items.slice(at + 1);
|
||||
let frames = 0;
|
||||
const wait = () => {
|
||||
if (removed.isConnected) {
|
||||
@@ -487,6 +491,8 @@ function refocusSlot(list: NavList, index: number, removed: HTMLElement): void {
|
||||
return;
|
||||
}
|
||||
if (!focusIsUnclaimed()) return;
|
||||
const next = after.find((el) => el.isConnected);
|
||||
if (next) return focusItem(next);
|
||||
const n = list.count();
|
||||
if (n > 0) focusIndex(list, Math.min(index, n - 1));
|
||||
};
|
||||
|
||||
@@ -148,6 +148,9 @@ const list = (id: number, items: Video[]) => ({
|
||||
items,
|
||||
});
|
||||
|
||||
/** The server's answer to a removal: the rows that went. */
|
||||
const wentOut = (...removed: string[]) => ({ ok: true, playlist_id: 1, removed });
|
||||
|
||||
let qc: QueryClient;
|
||||
|
||||
function mount(
|
||||
@@ -158,17 +161,21 @@ function mount(
|
||||
detail,
|
||||
linked = h.selectedId,
|
||||
listIn = false,
|
||||
rail,
|
||||
}: {
|
||||
strictOverCache?: boolean;
|
||||
detail?: (id: number) => Promise<unknown>;
|
||||
linked?: number | null;
|
||||
/** The account's list already in, as the rail that fetched it leaves it. */
|
||||
listIn?: boolean;
|
||||
/** What the account's list answers, when it is not the lists above. */
|
||||
rail?: () => Promise<unknown>;
|
||||
} = {},
|
||||
) {
|
||||
h.linkedId = linked;
|
||||
vi.spyOn(api, "playlists").mockResolvedValue(
|
||||
Object.entries(lists).map(([id, items]) => list(Number(id), items)) as never,
|
||||
vi.spyOn(api, "playlists").mockImplementation(
|
||||
(rail ??
|
||||
(async () => Object.entries(lists).map(([id, items]) => list(Number(id), items)))) as never,
|
||||
);
|
||||
vi.spyOn(api, "playlist").mockImplementation(
|
||||
(detail ?? (async (id: number) => list(id, lists[id] ?? []))) as never,
|
||||
@@ -409,7 +416,7 @@ describe("Playlists — removing a video from inside the player", () => {
|
||||
it("removes it from the list the player was launched from, even after the selection moved", async () => {
|
||||
// The selection can change under an open player (the command palette opens over it); the
|
||||
// removal read the LIVE selection, so it took the video out of a list the viewer never touched.
|
||||
const remove = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
|
||||
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("V") as never);
|
||||
const { rerender } = mount({ 1: [vid("V", 0)], 2: [vid("V", 0), vid("W", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.player).not.toBeNull());
|
||||
@@ -419,7 +426,7 @@ describe("Playlists — removing a video from inside the player", () => {
|
||||
await act(async () => {
|
||||
await h.player!.onRemoveFromList!(vid("V", 0));
|
||||
});
|
||||
expect(remove).toHaveBeenCalledWith(1, "V");
|
||||
expect(remove).toHaveBeenCalledWith(1, ["V"]);
|
||||
// …and the list on screen keeps its own copy of V: nothing was taken out of it.
|
||||
expect(screen.getByRole("button", { name: "card.play — Video V" })).toBeTruthy();
|
||||
});
|
||||
@@ -427,7 +434,9 @@ describe("Playlists — removing a video from inside the player", () => {
|
||||
it("says so when the removal fails because the list is gone", async () => {
|
||||
// `req` leaves 403/404 to the caller; the catch assumed a global dialog had already reported
|
||||
// it, so a confirmed removal failed in total silence behind the full-screen player.
|
||||
vi.spyOn(api, "removeFromPlaylist").mockRejectedValue(new HttpError(404, "Unknown playlist"));
|
||||
vi.spyOn(api, "removeManyFromPlaylist").mockRejectedValue(
|
||||
new HttpError(404, "Unknown playlist"),
|
||||
);
|
||||
mount({ 1: [vid("V", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.player).not.toBeNull());
|
||||
@@ -458,7 +467,7 @@ describe("Playlists — removing a video from inside the player", () => {
|
||||
// The player steps to the next item after a removal and stores the last one's position into
|
||||
// every cached list. Landing before the refetch, that write handed the page a list still holding
|
||||
// the video, and the page — seeing a different set of ids — reset: the row came back.
|
||||
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
|
||||
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("V") as never);
|
||||
mount({ 1: [vid("V", 0), vid("W", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.player).not.toBeNull());
|
||||
@@ -681,12 +690,12 @@ describe("Playlists — a change the server refused is taken back on screen", ()
|
||||
it("puts back a row whose removal failed, even when the resync fails too", async () => {
|
||||
// A removal during an API restart: the DELETE fails, and so does the refetch meant to put the row
|
||||
// back — the list kept showing it as removed while it was still in the playlist.
|
||||
vi.spyOn(api, "removeFromPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
vi.spyOn(api, "removeManyFromPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
mount({ 1: [vid("V", 0), vid("W", 0)] });
|
||||
await screen.findByRole("button", { name: "card.play — Video V" });
|
||||
vi.mocked(api.playlist).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
await rowMenu("Video V", "playlists.removeItem");
|
||||
await waitFor(() => expect(api.removeFromPlaylist).toHaveBeenCalled());
|
||||
await waitFor(() => expect(api.removeManyFromPlaylist).toHaveBeenCalled());
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
@@ -700,10 +709,13 @@ describe("Playlists — a change the server refused is taken back on screen", ()
|
||||
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;
|
||||
});
|
||||
vi.spyOn(api, "removeManyFromPlaylist").mockImplementation((async (
|
||||
id: number,
|
||||
videoIds: string[],
|
||||
) => {
|
||||
lists[id] = lists[id]!.filter((v) => !videoIds.includes(v.id));
|
||||
return wentOut(...videoIds);
|
||||
}) 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 = () =>
|
||||
@@ -727,10 +739,10 @@ describe("Playlists — removing a row, and undoing it", () => {
|
||||
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);
|
||||
vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true, added: 1 } as never);
|
||||
mount(lists);
|
||||
await rowMenu("Video B", "playlists.removeItem");
|
||||
await waitFor(() => expect(api.removeFromPlaylist).toHaveBeenCalledWith(1, "B"));
|
||||
await waitFor(() => expect(api.removeManyFromPlaylist).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();
|
||||
@@ -740,13 +752,13 @@ describe("Playlists — removing a row, and undoing it", () => {
|
||||
expect(lastToast().action?.label).toEqual(msg("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));
|
||||
await waitFor(() => expect(api.restoreToPlaylist).toHaveBeenCalledWith(1, ["B"], [1]));
|
||||
});
|
||||
|
||||
it("puts it back where it stood in the stored order, whatever the view on screen", async () => {
|
||||
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
|
||||
serverRemoves(lists);
|
||||
vi.spyOn(api, "addToPlaylist").mockResolvedValue(undefined as never);
|
||||
vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true, added: 1 } as never);
|
||||
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.
|
||||
@@ -756,7 +768,7 @@ describe("Playlists — removing a row, and undoing it", () => {
|
||||
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));
|
||||
await waitFor(() => expect(api.restoreToPlaylist).toHaveBeenCalledWith(1, ["A"], [0]));
|
||||
});
|
||||
|
||||
// The stored index drifts when the list changes before the Undo (a row added at
|
||||
@@ -764,19 +776,45 @@ describe("Playlists — removing a row, and undoing it", () => {
|
||||
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);
|
||||
vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true, added: 1 } 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));
|
||||
await waitFor(() => expect(api.restoreToPlaylist).toHaveBeenCalledWith(1, ["B"], [2]));
|
||||
});
|
||||
|
||||
it("says so when the video could not come back, rather than nothing at all", async () => {
|
||||
const lists = { 1: [vid("A", 0), vid("B", 0)] };
|
||||
serverRemoves(lists);
|
||||
vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true, added: 0 } as never);
|
||||
mount(lists);
|
||||
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: "warning", message: msg("playlists.undoNoneBack") }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes what Add to playlist knows about a removed video", async () => {
|
||||
const lists = { 1: [vid("A", 0), vid("B", 0)] };
|
||||
serverRemoves(lists);
|
||||
mount(lists);
|
||||
await screen.findByRole("button", { name: "card.play — Video A" });
|
||||
qc.setQueryData(qk.playlistMembership("A"), { in: [1] });
|
||||
await rowMenu("Video A", "playlists.removeItem");
|
||||
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
|
||||
expect(qc.getQueryState(qk.playlistMembership("A"))?.isInvalidated).toBe(true);
|
||||
});
|
||||
|
||||
it("says so when the Undo fails", async () => {
|
||||
const lists = { 1: [vid("A", 0), vid("B", 0)] };
|
||||
serverRemoves(lists);
|
||||
vi.spyOn(api, "addToPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
vi.spyOn(api, "restoreToPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
mount(lists);
|
||||
await rowMenu("Video A", "playlists.removeItem");
|
||||
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
|
||||
@@ -797,20 +835,20 @@ describe("Playlists — removing a row, and undoing it", () => {
|
||||
pressKey("j");
|
||||
expect(document.activeElement?.textContent).toContain("Video B");
|
||||
pressKey("Delete");
|
||||
await waitFor(() => expect(api.removeFromPlaylist).toHaveBeenCalledWith(1, "B"));
|
||||
await waitFor(() => expect(api.removeManyFromPlaylist).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);
|
||||
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut() as never);
|
||||
h.selectedId = WATCH_LATER;
|
||||
mount({ [WATCH_LATER]: [vid("A", 0), vid("B", 0)] });
|
||||
await keysLiveOn(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(api.removeManyFromPlaylist).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("button", { name: /removeItemOf/ })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1126,7 +1164,7 @@ describe("Playlists — selecting rows", () => {
|
||||
});
|
||||
|
||||
it("drops a row removed from the list out of the selection", async () => {
|
||||
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({ ok: true } as never);
|
||||
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("A") as never);
|
||||
mount(three);
|
||||
fireEvent.click(await box("Video A"));
|
||||
fireEvent.click(await box("Video B"));
|
||||
@@ -1394,6 +1432,7 @@ describe("Playlists — dragging rows", () => {
|
||||
});
|
||||
|
||||
describe("under a sort or a filter", () => {
|
||||
const keyStarts = () => dnd.props.sensors[1].options.keyboardCodes?.start;
|
||||
const filter = async (value: string) => {
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
|
||||
fireEvent.change(await screen.findByPlaceholderText("playlists.colTitle"), {
|
||||
@@ -1422,9 +1461,9 @@ describe("Playlists — dragging rows", () => {
|
||||
const order = vi.spyOn(api, "reorderPlaylist").mockResolvedValue({} as never);
|
||||
mount(abc);
|
||||
await aRow("A");
|
||||
expect(dnd.props.sensors).toHaveLength(2);
|
||||
expect(keyStarts()).toBeUndefined();
|
||||
await filter("Video");
|
||||
expect(dnd.props.sensors).toHaveLength(1);
|
||||
expect(keyStarts()).toEqual([]);
|
||||
overNothing();
|
||||
pickUp("A");
|
||||
expect(collide()).toEqual([]);
|
||||
@@ -1433,6 +1472,33 @@ describe("Playlists — dragging rows", () => {
|
||||
expect(order).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a sort", "plsort=title:desc"],
|
||||
["a grouping", "plgroup=1"],
|
||||
])("holds under %s as under a filter", async (_view, query) => {
|
||||
window.history.replaceState(null, "", `/?page=playlists&pl=1&${query}`);
|
||||
const order = vi.spyOn(api, "reorderPlaylist").mockResolvedValue({} as never);
|
||||
mount(abc);
|
||||
await aRow("A");
|
||||
expect(keyStarts()).toEqual([]);
|
||||
await screen.findByRole("button", { name: "playlists.dragHandleToList:Video A" });
|
||||
overNothing();
|
||||
pickUp("A");
|
||||
expect(collide()).toEqual([]);
|
||||
dropOn("A");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(order).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the same sensors, so a view turning on logs no changed dependency list", async () => {
|
||||
const logged = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mount(abc);
|
||||
await aRow("A");
|
||||
await filter("Video");
|
||||
expect(logged.mock.calls.filter((c) => String(c[0]).includes("changed size"))).toEqual([]);
|
||||
logged.mockRestore();
|
||||
});
|
||||
|
||||
it("explains the grip with no other list to go to", async () => {
|
||||
mount({ 1: abc[1]! });
|
||||
await aRow("A");
|
||||
@@ -1442,9 +1508,56 @@ describe("Playlists — dragging rows", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("spends the drag's Esc on the drag: the ticks stay, and the next Esc clears them", async () => {
|
||||
mount(abc);
|
||||
await tick("Video A", "Video B");
|
||||
overNothing();
|
||||
pickUp("A");
|
||||
const esc = () =>
|
||||
act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
esc();
|
||||
act(() => {
|
||||
dnd.props.onDragCancel();
|
||||
});
|
||||
expect(((await box("Video A")) as HTMLInputElement).checked).toBe(true);
|
||||
expect(((await box("Video B")) as HTMLInputElement).checked).toBe(true);
|
||||
esc();
|
||||
expect(((await box("Video A")) as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves the list where it is while the drag looks for a list in the rail", async () => {
|
||||
mount(abc);
|
||||
await aRow("A");
|
||||
overRail(2);
|
||||
pickUp("A");
|
||||
expect(dnd.props.autoScroll.canScroll()).toBe(false);
|
||||
act(() => {
|
||||
dnd.props.onDragCancel();
|
||||
});
|
||||
overNothing();
|
||||
pickUp("A");
|
||||
expect(dnd.props.autoScroll.canScroll()).toBe(true);
|
||||
});
|
||||
|
||||
describe("what a screen reader hears", () => {
|
||||
const say = () => dnd.props.accessibility.announcements;
|
||||
|
||||
it("counts them when many stay put, whichever way the drag ends", async () => {
|
||||
mount(abc);
|
||||
await tick("Video A", "Video B");
|
||||
overNothing();
|
||||
pickUp("A");
|
||||
expect(say().onDragCancel({ active: { id: "A" } })).toBe("playlists.dragCancelledMany");
|
||||
dropOn("A");
|
||||
expect(say().onDragEnd({ active: { id: "A" }, over: null })).toBe(
|
||||
"playlists.dragCancelledMany",
|
||||
);
|
||||
});
|
||||
|
||||
it("names how many are picked up, and the list under the drag", async () => {
|
||||
mount(abc);
|
||||
await tick("Video A", "Video B");
|
||||
@@ -1509,6 +1622,14 @@ describe("Playlists — acting on the selected rows", () => {
|
||||
action?: { onClick: () => void };
|
||||
};
|
||||
const button = (name: string) => screen.findByRole("button", { name });
|
||||
const menuRow = (label: string) =>
|
||||
waitFor(() => {
|
||||
const row = screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === label);
|
||||
if (!row) throw new Error(`no menu row "${label}"`);
|
||||
return row;
|
||||
});
|
||||
const moved = (ids: string[], already_there: string[] = []) => ({
|
||||
ok: true,
|
||||
from_playlist_id: 1,
|
||||
@@ -1527,7 +1648,7 @@ describe("Playlists — acting on the selected rows", () => {
|
||||
expect(screen.queryByText("playlists.selectedCount")).toBeNull();
|
||||
expect(screen.getByRole("checkbox", { name: "playlists.groupByChannel" })).toBeTruthy();
|
||||
await tick("Video A");
|
||||
expect(await screen.findByText("playlists.selectedCount")).toBeTruthy();
|
||||
expect(await screen.findAllByText("playlists.selectedCount")).toBeTruthy();
|
||||
expect(screen.queryByRole("checkbox", { name: "playlists.groupByChannel" })).toBeNull();
|
||||
fireEvent.click(await button("playlists.clearSelection"));
|
||||
expect(screen.queryByText("playlists.selectedCount")).toBeNull();
|
||||
@@ -1659,9 +1780,11 @@ describe("Playlists — acting on the selected rows", () => {
|
||||
const lists = { ...abc };
|
||||
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockImplementation((async () => {
|
||||
lists[1] = lists[1]!.filter((v) => v.id === "B");
|
||||
return { ok: true, playlist_id: 1, removed: 2 };
|
||||
return wentOut("A", "C");
|
||||
}) as never);
|
||||
const restore = vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true } as never);
|
||||
const restore = vi
|
||||
.spyOn(api, "restoreToPlaylist")
|
||||
.mockResolvedValue({ ok: true, added: 2 } as never);
|
||||
mount(lists);
|
||||
await tick("Video A", "Video C");
|
||||
fireEvent.click(await button("playlists.removeSelected"));
|
||||
@@ -1686,11 +1809,7 @@ describe("Playlists — acting on the selected rows", () => {
|
||||
});
|
||||
|
||||
it("names the one video when one was removed", async () => {
|
||||
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({
|
||||
ok: true,
|
||||
playlist_id: 1,
|
||||
removed: 1,
|
||||
} as never);
|
||||
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("B") as never);
|
||||
mount(abc);
|
||||
await tick("Video B");
|
||||
fireEvent.click(await button("playlists.removeSelected"));
|
||||
@@ -1701,25 +1820,22 @@ describe("Playlists — acting on the selected rows", () => {
|
||||
it("has Delete take the selection when there is one, with the focus on a box", async () => {
|
||||
const remove = vi
|
||||
.spyOn(api, "removeManyFromPlaylist")
|
||||
.mockResolvedValue({ ok: true, playlist_id: 1, removed: 2 } as never);
|
||||
const single = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({ ok: true } as never);
|
||||
.mockResolvedValue(wentOut("A", "B") as never);
|
||||
mount(abc);
|
||||
await tick("Video A", "Video B");
|
||||
(await box("Video B")).focus();
|
||||
await act(async () => press("Delete"));
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledTimes(1));
|
||||
expect(single).not.toHaveBeenCalled();
|
||||
expect([...remove.mock.calls[0]![1]].sort()).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
it("has Delete take the walked row when nothing is selected", async () => {
|
||||
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({} as never);
|
||||
const single = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({ ok: true } as never);
|
||||
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("A") as never);
|
||||
mount(abc);
|
||||
await box("Video A");
|
||||
press("j");
|
||||
await act(async () => press("Delete"));
|
||||
await waitFor(() => expect(single).toHaveBeenCalledWith(1, "A"));
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith(1, ["A"]));
|
||||
});
|
||||
|
||||
it("keeps a row's own menu on that row, and leaves the selection alone", async () => {
|
||||
@@ -1751,9 +1867,7 @@ describe("Playlists — acting on the selected rows", () => {
|
||||
});
|
||||
|
||||
it("acts on the rows shown, says how many a filter hides, and keeps the view's reset", async () => {
|
||||
const remove = vi
|
||||
.spyOn(api, "removeManyFromPlaylist")
|
||||
.mockResolvedValue({ ok: true, playlist_id: 1, removed: 1 } as never);
|
||||
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("B") as never);
|
||||
mount(abc);
|
||||
await tick("Video A", "Video B");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
|
||||
@@ -1767,11 +1881,7 @@ describe("Playlists — acting on the selected rows", () => {
|
||||
});
|
||||
|
||||
it("forgets the ticks a filter hid once it acted, too", async () => {
|
||||
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({
|
||||
ok: true,
|
||||
playlist_id: 1,
|
||||
removed: 1,
|
||||
} as never);
|
||||
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("B") as never);
|
||||
mount(abc);
|
||||
await tick("Video A", "Video B");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
|
||||
@@ -1808,7 +1918,7 @@ describe("Playlists — acting on the selected rows", () => {
|
||||
const count = (await screen.findByText(/playlists\.itemCount/)).parentElement!;
|
||||
expect(count.classList.contains("invisible")).toBe(false);
|
||||
await tick("Video A");
|
||||
const band = (await screen.findByText("playlists.selectedCount")).closest("[role=status]")!;
|
||||
const band = await button("playlists.clearSelection");
|
||||
expect(count.classList.contains("invisible")).toBe(true);
|
||||
expect(count.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(count.parentElement).toBe(band.parentElement!.parentElement!.parentElement);
|
||||
@@ -1878,11 +1988,7 @@ describe("Playlists — acting on the selected rows", () => {
|
||||
});
|
||||
|
||||
it("hands the focus to the list's menu when the band's action emptied the list", async () => {
|
||||
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({
|
||||
ok: true,
|
||||
playlist_id: 1,
|
||||
removed: 1,
|
||||
} as never);
|
||||
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("A") as never);
|
||||
mount({ 1: [vid("A", 0)] });
|
||||
await tick("Video A");
|
||||
const remove = await button("playlists.removeSelected");
|
||||
@@ -1892,6 +1998,237 @@ describe("Playlists — acting on the selected rows", () => {
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "playlists.railMenu" }));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the header's box", () => screen.getByRole("checkbox", { name: "playlists.selectAllShown" })],
|
||||
["a row's box", () => screen.getByRole("checkbox", { name: "playlists.selectRow:Video A" })],
|
||||
])("hands the focus to the list's menu when Delete on %s emptied the list", async (_n, held) => {
|
||||
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("A") as never);
|
||||
mount({ 1: [vid("A", 0)] });
|
||||
await tick("Video A");
|
||||
held().focus();
|
||||
await act(async () => press("Delete"));
|
||||
await waitFor(() => expect(toast().action).toBeTruthy());
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "playlists.railMenu" }));
|
||||
});
|
||||
|
||||
it("goes on to the next row left, past the rows taken from above the focus", async () => {
|
||||
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("A", "B", "C") as never);
|
||||
mount({ 1: ["A", "B", "C", "D", "E"].map((id) => vid(id, 0)) });
|
||||
await tick("Video A", "Video B", "Video C");
|
||||
(await box("Video C")).focus();
|
||||
await act(async () => press("Delete"));
|
||||
await waitFor(() => expect(document.activeElement?.textContent).toContain("Video D"));
|
||||
});
|
||||
|
||||
it("takes no row at all on Delete when a filter hides every tick, and says why", async () => {
|
||||
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut() as never);
|
||||
mount(abc);
|
||||
await tick("Video A");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
|
||||
fireEvent.change(await screen.findByPlaceholderText("playlists.colTitle"), {
|
||||
target: { value: "Video B" },
|
||||
});
|
||||
await screen.findByText("playlists.selectedHidden");
|
||||
(await screen.findByRole("button", { name: "card.play — Video B" })).focus();
|
||||
await act(async () => press("Delete"));
|
||||
expect(toast().message).toEqual(msg("playlists.removeAllHidden"));
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
expect(rowsOnScreen()).toEqual(["Video B"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the band's", true],
|
||||
["the meta line's", false],
|
||||
])("hands the focus on when %s Reset view goes with the view", async (_n, selecting) => {
|
||||
window.history.replaceState(null, "", "/?page=playlists&pl=1&plsort=title:desc");
|
||||
mount(abc);
|
||||
if (selecting) await tick("Video A");
|
||||
const reset = await button("playlists.clearView");
|
||||
reset.focus();
|
||||
fireEvent.click(reset);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: "playlists.clearView" })).toBeNull(),
|
||||
);
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole("checkbox", { name: "playlists.selectAllShown" }),
|
||||
);
|
||||
});
|
||||
|
||||
// The answer comes later than the render, as a server's does: until then the row has left the list,
|
||||
// and its tick with it.
|
||||
it.each([
|
||||
["move", "moveManyToPlaylist", "playlists.moveToRecent"],
|
||||
["removal", "removeManyFromPlaylist", "playlists.removeItem"],
|
||||
] as const)("ticks a row again when its own menu's %s failed", async (_n, call, item) => {
|
||||
let fail: (e: unknown) => void = () => {};
|
||||
vi.spyOn(api, call).mockImplementation(
|
||||
(() =>
|
||||
new Promise((_ok, no) => {
|
||||
fail = no;
|
||||
})) as never,
|
||||
);
|
||||
mount(abc, { lastMovePlaylistId: 2 });
|
||||
await tick("Video A", "Video B");
|
||||
await rowMenu("Video A", item);
|
||||
await waitFor(() => expect(rowsOnScreen()).toEqual(["Video B", "Video C"]));
|
||||
await act(async () => fail(new HttpError(500, "boom")));
|
||||
await waitFor(() => expect(rowsOnScreen()).toEqual(["Video A", "Video B", "Video C"]));
|
||||
await waitFor(() =>
|
||||
expect(ticked()).toEqual(["playlists.selectRow:Video A", "playlists.selectRow:Video B"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("ticks nothing on the list now on screen when a move from another one fails", async () => {
|
||||
let fail: (e: unknown) => void = () => {};
|
||||
const move = vi.spyOn(api, "moveManyToPlaylist").mockImplementation(
|
||||
(() =>
|
||||
new Promise((_ok, no) => {
|
||||
fail = no;
|
||||
})) as never,
|
||||
);
|
||||
const { rerender } = mount(
|
||||
{ 1: [vid("A", 0), vid("B", 0)], 2: [vid("A", 0)], 3: [] },
|
||||
{ lastMovePlaylistId: 3 },
|
||||
);
|
||||
await tick("Video A");
|
||||
fireEvent.click(await button("playlists.moveToRecent"));
|
||||
await waitFor(() => expect(move).toHaveBeenCalled());
|
||||
h.selectedId = 2;
|
||||
rerender();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: "card.play — Video B" })).toBeNull(),
|
||||
);
|
||||
await box("Video A");
|
||||
await act(async () => fail(new HttpError(500, "boom")));
|
||||
await waitFor(() => expect(toast().level).toBe("error"));
|
||||
expect(((await box("Video A")) as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
|
||||
it("says out loud the count, and that the selection went", async () => {
|
||||
mount(abc);
|
||||
await box("Video A");
|
||||
const status = () => document.querySelector(".sr-only[role=status]")!.textContent;
|
||||
expect(status()).toBe("");
|
||||
await tick("Video A");
|
||||
await waitFor(() => expect(status()).toBe("playlists.selectedCount"));
|
||||
await act(async () => press("Escape"));
|
||||
await waitFor(() => expect(status()).toBe("playlists.selectionCleared"));
|
||||
});
|
||||
|
||||
it("moves to the list picked in the band's menu, not the last one", async () => {
|
||||
const move = vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A"]) as never);
|
||||
mount({ ...abc, 3: [] }, { lastMovePlaylistId: 2 });
|
||||
await tick("Video A");
|
||||
fireEvent.click(await button("playlists.moveSelected"));
|
||||
fireEvent.click(await menuRow("List 3"));
|
||||
await waitFor(() => expect(move).toHaveBeenCalledWith(1, ["A"], 3));
|
||||
});
|
||||
|
||||
it("moves to the list picked in a row's own menu", async () => {
|
||||
const move = vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A"]) as never);
|
||||
mount({ ...abc, 3: [] }, { lastMovePlaylistId: 2 });
|
||||
await box("Video A");
|
||||
await rowMenu("Video A", "playlists.moveTo");
|
||||
fireEvent.click(await menuRow("List 3"));
|
||||
await waitFor(() => expect(move).toHaveBeenCalledWith(1, ["A"], 3));
|
||||
});
|
||||
|
||||
it("marks the target's cached list stale too, not only the one on screen", async () => {
|
||||
vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A"]) as never);
|
||||
mount(abc, { lastMovePlaylistId: 2 });
|
||||
await tick("Video A");
|
||||
qc.setQueryData(qk.playlist(2), list(2, []));
|
||||
fireEvent.click(await button("playlists.moveToRecent"));
|
||||
await waitFor(() => expect(toast().action).toBeTruthy());
|
||||
expect(qc.getQueryState(qk.playlist(2))?.isInvalidated).toBe(true);
|
||||
});
|
||||
|
||||
it("says so when none of them was in the list any more, with no Undo to offer: a removal", async () => {
|
||||
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut() as never);
|
||||
mount(abc);
|
||||
await tick("Video A", "Video B");
|
||||
fireEvent.click(await button("playlists.removeSelected"));
|
||||
await waitFor(() => expect(toast().message).toEqual(msg("playlists.removeNoneLeft")));
|
||||
expect(toast().action).toBeUndefined();
|
||||
});
|
||||
|
||||
it("counts what really went, and its Undo brings back only those", async () => {
|
||||
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("A", "C") as never);
|
||||
const restore = vi
|
||||
.spyOn(api, "restoreToPlaylist")
|
||||
.mockResolvedValue({ ok: true, added: 2 } as never);
|
||||
mount(abc);
|
||||
await tick("Video A", "Video B", "Video C");
|
||||
fireEvent.click(await button("playlists.removeSelected"));
|
||||
await waitFor(() => expect(toast().action).toBeTruthy());
|
||||
expect(toast().message).toEqual(msg("playlists.removedManyToast", { count: 2 }));
|
||||
vi.spyOn(api, "playlist").mockResolvedValue(list(1, []) as never);
|
||||
toast().action!.onClick();
|
||||
await waitFor(() => expect(restore).toHaveBeenCalledWith(1, ["A", "C"], [0, 1]));
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, msg("playlists.undoNoneBack")],
|
||||
[1, msg("playlists.undoSomeBack", { count: 1 })],
|
||||
])("says what came back when a move's Undo brought %i of 2", async (added, said) => {
|
||||
vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A", "B"]) as never);
|
||||
vi.spyOn(api, "undoPlaylistMove").mockResolvedValue({ ok: true, added } as never);
|
||||
mount(abc, { lastMovePlaylistId: 2 });
|
||||
await tick("Video A", "Video B");
|
||||
fireEvent.click(await button("playlists.moveToRecent"));
|
||||
await waitFor(() => expect(toast().action).toBeTruthy());
|
||||
vi.spyOn(api, "playlist").mockResolvedValue(list(1, [vid("C", 0)]) as never);
|
||||
toast().action!.onClick();
|
||||
await waitFor(() =>
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "warning", message: said }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("says nothing more when a move's Undo brought them all", async () => {
|
||||
vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A"]) as never);
|
||||
const undo = vi
|
||||
.spyOn(api, "undoPlaylistMove")
|
||||
.mockResolvedValue({ ok: true, added: 1 } as never);
|
||||
mount(abc, { lastMovePlaylistId: 2 });
|
||||
await tick("Video A");
|
||||
fireEvent.click(await button("playlists.moveToRecent"));
|
||||
await waitFor(() => expect(toast().action).toBeTruthy());
|
||||
const toasts = h.notify.mock.calls.length;
|
||||
vi.spyOn(api, "playlist").mockResolvedValue(list(1, [vid("B", 0), vid("C", 0)]) as never);
|
||||
toast().action!.onClick();
|
||||
await waitFor(() => expect(undo).toHaveBeenCalled());
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||
expect(h.notify.mock.calls.length).toBe(toasts);
|
||||
});
|
||||
|
||||
it("does not call a list that failed to load no other list", async () => {
|
||||
mount(
|
||||
{ 1: [vid("A", 0)] },
|
||||
{},
|
||||
{ rail: async () => Promise.reject(new HttpError(500, "boom")) },
|
||||
);
|
||||
await tick("Video A");
|
||||
fireEvent.click(await button("playlists.moveSelected"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("menuitem", { hidden: true }).textContent).toContain(
|
||||
"playlists.moveTargetsFailed",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call a list still loading no other list", async () => {
|
||||
mount({ 1: [vid("A", 0)] }, {}, { rail: () => new Promise(() => {}) });
|
||||
await tick("Video A");
|
||||
fireEvent.click(await button("playlists.moveSelected"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("menuitem", { hidden: true }).textContent).toContain(
|
||||
"playlists.moveTargetsLoading",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the focus alone when the band goes and it was elsewhere", async () => {
|
||||
mount(abc);
|
||||
await tick("Video A");
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent,
|
||||
} from "react";
|
||||
import { useLatest, useStableCallback } from "../../lib/useLatest";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { hasResumePoint } from "../../lib/api/feed";
|
||||
import {
|
||||
addPlaylistItem,
|
||||
movePlaylistItems,
|
||||
removePlaylistItem,
|
||||
removePlaylistItems,
|
||||
restorePlaylistItems,
|
||||
undoPlaylistMove,
|
||||
@@ -21,6 +27,7 @@ import {
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
DragOverlay,
|
||||
KeyboardCode,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type CollisionDetection,
|
||||
@@ -68,6 +75,7 @@ import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { useComfortPin, pinPlaylist, pinVideo } from "../../features/comfort/comfortPin";
|
||||
import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||
import Overlay from "../../components/ui/Overlay";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import { usePageMenu } from "../../components/ui/pageMenuSlot";
|
||||
import { menuGroups } from "../../components/ui/menuGroups";
|
||||
@@ -136,6 +144,15 @@ const moveTargetIcon = (pl: Playlist) =>
|
||||
<ListPlus className="w-4 h-4" />
|
||||
);
|
||||
|
||||
/** Under a view a key would pick the row up with nowhere to put it: only the rail is left to go to.
|
||||
* No key starts it, rather than no such sensor: dnd-kit takes the sensors as an effect's dependency
|
||||
* list, whose size must not change. */
|
||||
const NO_KEY_STARTS = {
|
||||
start: [],
|
||||
cancel: [KeyboardCode.Esc],
|
||||
end: [KeyboardCode.Space, KeyboardCode.Enter, KeyboardCode.Tab],
|
||||
};
|
||||
|
||||
/** A pointer drag lands where the pointer is. dnd-kit measures from the drag image, whose small
|
||||
* box has its centre elsewhere: on a tablet's taller rows a drop reached the row before. */
|
||||
function pointerFirst(args: Parameters<CollisionDetection>[0]): Parameters<CollisionDetection>[0] {
|
||||
@@ -421,11 +438,13 @@ function PlaylistDetail() {
|
||||
// sort, filters and grouping. All three are this component's own state, which is what makes a
|
||||
// render-phase write legal here.
|
||||
const [viewFor, setViewFor] = useState(selectedId);
|
||||
const [bandWasUp, setBandWasUp] = useState(false);
|
||||
if (viewFor !== selectedId) {
|
||||
setViewFor(selectedId);
|
||||
setViewSort(STORED_SORT);
|
||||
setFilters({});
|
||||
setGroupBy(false);
|
||||
setBandWasUp(false);
|
||||
}
|
||||
useUrlMirror(
|
||||
playlistViewParamUpdates(selectedId != null ? { sort: viewSort, filters, groupBy } : null),
|
||||
@@ -515,6 +534,7 @@ function PlaylistDetail() {
|
||||
);
|
||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||
const bandRef = useRef<HTMLDivElement>(null);
|
||||
const tableRef = useRef<HTMLTableElement>(null);
|
||||
// The band's own "Move to" list, the row menu's with no row behind it.
|
||||
const moveMenu = useContextMenu<"selection">();
|
||||
const tableColumns = canEdit
|
||||
@@ -541,6 +561,18 @@ function PlaylistDetail() {
|
||||
const viewActive = !isStoredSort(viewSort) || groupBy || filterActive;
|
||||
// While anything is selected, shown or not: a tick only a filter hides still needs its way out.
|
||||
const bandUp = canEdit && selection.shownSelected.length + selection.hiddenCount > 0;
|
||||
if (bandUp && !bandWasUp) setBandWasUp(true);
|
||||
// Out loud from a region that outlives the band: its news includes the band going.
|
||||
const selectionStatus = bandUp
|
||||
? [
|
||||
t("playlists.selectedCount", { count: selection.shownSelected.length }),
|
||||
...(selection.hiddenCount > 0
|
||||
? [t("playlists.selectedHidden", { count: selection.hiddenCount })]
|
||||
: []),
|
||||
].join(" · ")
|
||||
: bandWasUp
|
||||
? t("playlists.selectionCleared")
|
||||
: "";
|
||||
// Saving a FILTERED view would drop every hidden item from the playlist, so the button only
|
||||
// offers itself when the view is a pure reordering of the whole list.
|
||||
const canSaveOrder = canEdit && !filterActive && !sameOrder(shown, items);
|
||||
@@ -557,6 +589,13 @@ function PlaylistDetail() {
|
||||
setFilters({});
|
||||
setGroupBy(false);
|
||||
}
|
||||
// From a button that goes with the view: a focus on it would fall to <body>, and the next Tab start
|
||||
// over at the top of the page.
|
||||
const resetView = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (document.activeElement === e.currentTarget)
|
||||
(selectAllRef.current ?? renameTarget.current)?.focus();
|
||||
clearView();
|
||||
};
|
||||
function saveOrder() {
|
||||
order.set(shown); // onApply persists it
|
||||
clearView();
|
||||
@@ -565,10 +604,11 @@ function PlaylistDetail() {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
// The grip was focusable and called itself "reorder", but only a pointer could move it.
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
keyboardCodes: viewActive ? NO_KEY_STARTS : undefined,
|
||||
}),
|
||||
);
|
||||
// Under a view a key would pick the row up with nowhere to put it: only the rail is left to go to.
|
||||
const pointerSensorOnly = sensors.slice(0, 1);
|
||||
|
||||
// Named and placed from `shownRef`, read at announcement time (see ui/sortableA11y).
|
||||
const dndA11y = useSortableA11y((id) => {
|
||||
@@ -582,11 +622,6 @@ function PlaylistDetail() {
|
||||
|
||||
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
|
||||
|
||||
function refreshAll(listId: number | null = selectedId) {
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
qc.invalidateQueries({ queryKey: qk.playlist(listId) });
|
||||
}
|
||||
|
||||
/** The rows on the drag image. A selected row carries the others; a key moves only its own row. */
|
||||
const [carried, setCarried] = useState<readonly string[] | null>(null);
|
||||
const carriedRef = useRef<readonly string[]>([]);
|
||||
@@ -643,82 +678,10 @@ function PlaylistDetail() {
|
||||
|
||||
/** Takes a row out at once and says so in a toast whose Undo puts it back where it was (the
|
||||
* X column became the Delete key + this toast; no confirm, because nothing is lost).
|
||||
* @returns whether the item was actually removed — the player needs to know, because it must
|
||||
* step to the next item (or close) only when the removal really happened. */
|
||||
async function removeItem(video: Video, listIdArg?: number | null): Promise<boolean> {
|
||||
// Defaulted here, not as `listId = selectedRef.current` in the signature: the React Compiler
|
||||
// cannot reorder a ref read in a default parameter and skipped the component for it.
|
||||
const listId = listIdArg === undefined ? selectedRef.current : listIdArg;
|
||||
if (listId == null) return false;
|
||||
// Only the list ON SCREEN has rows to take the item out of optimistically — the open player can
|
||||
// be removing from the list it was launched from while another one is selected.
|
||||
if (listId === selectedRef.current) {
|
||||
// A removal isn't itself on the reorder undo stack, but it no longer THROWS AWAY that history:
|
||||
// rebasing drops the item from every snapshot, so the stack stays valid for what's left.
|
||||
const next = order.value.filter((v) => v.id !== video.id);
|
||||
order.rebase((list) => list.filter((v) => v.id !== video.id));
|
||||
lastSetRef.current = `${listId}:${idsKey(next)}`;
|
||||
}
|
||||
// …and out of the CACHED list too, not just the rows. Anything that rewrites that cache entry
|
||||
// before the refetch lands handed the membership guard a list that still held the video — the
|
||||
// player's own progress checkpoint does exactly that when a removal steps it to the next item —
|
||||
// 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));
|
||||
// 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,
|
||||
);
|
||||
try {
|
||||
await removePlaylistItem(qc, listRef(listId), video.id);
|
||||
refreshAll(listId);
|
||||
} catch (e) {
|
||||
// Said here, as moveItemTo does, or a removal fails in silence (inside the full-screen player
|
||||
// there is not even a row snapping back to show it) — unless `req` already put it in its error
|
||||
// dialog, which `notifyActionError` checks.
|
||||
notifyActionError(e, msg("playlists.removeFailed"));
|
||||
// …and the row goes back NOW, not when the resync lands: a removal that failed because the
|
||||
// server was unreachable has a resync that fails too, and the list kept showing the row as
|
||||
// removed while it was still in the playlist.
|
||||
if (before) qc.setQueryData(qk.playlist(listId), before);
|
||||
refreshAll(listId);
|
||||
return false;
|
||||
}
|
||||
notify({
|
||||
level: "success",
|
||||
message: msg("playlists.removedToast", { title: raw(video.title ?? "") }),
|
||||
action: {
|
||||
label: msg("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. The
|
||||
// index is worked out against the list as it is NOW, fetched fresh.
|
||||
onClick: () =>
|
||||
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 addPlaylistItem(qc, listRef(listId), video.id, back.positions[0]);
|
||||
})
|
||||
.catch((e: unknown) => notifyActionError(e, msg("playlists.undoRemoveFailed")))
|
||||
.finally(() => {
|
||||
refreshAll(listId);
|
||||
qc.invalidateQueries({ queryKey: qk.playlistMembership(video.id) });
|
||||
}),
|
||||
},
|
||||
});
|
||||
return true;
|
||||
* @returns whether the write went through — the player needs to know, because it must step to the
|
||||
* next item (or close) only then. */
|
||||
function removeItem(video: Video, listId?: number | null): Promise<boolean> {
|
||||
return removeRows([video], tickOf(video), listId);
|
||||
}
|
||||
// The drag announcements read `shownRef` too: the CURRENT positions, without making their closures
|
||||
// a render dependency of DndContext; `removeRef` keeps the memoized column callbacks stable.
|
||||
@@ -756,13 +719,29 @@ function PlaylistDetail() {
|
||||
linkedOutOfReach && selectedId === railMovedTo ? linkedOver : selectedId,
|
||||
);
|
||||
|
||||
// Off the screen at once, and out of the cached list too, for the reason `removeItem` gives. Returns
|
||||
// the cached list as it stood: the place an Undo goes back to, and what a failure puts back.
|
||||
/** Off the screen at once. Returns the cached list as it stood: the place an Undo goes back to, and
|
||||
* what a failure puts back. */
|
||||
function takeOut(listId: number, ids: readonly string[]) {
|
||||
const gone = new Set(ids);
|
||||
const next = order.value.filter((v) => !gone.has(v.id));
|
||||
order.rebase((list) => list.filter((v) => !gone.has(v.id)));
|
||||
lastSetRef.current = `${listId}:${idsKey(next)}`;
|
||||
// Only the list ON SCREEN has rows to take out optimistically — the open player can be removing
|
||||
// from the list it was launched from while another one is selected.
|
||||
if (listId === selectedRef.current) {
|
||||
// Not itself on the reorder undo stack, but it no longer THROWS AWAY that history: rebasing
|
||||
// drops the rows from every snapshot, so the stack stays valid for what's left.
|
||||
const next = order.value.filter((v) => !gone.has(v.id));
|
||||
order.rebase((list) => list.filter((v) => !gone.has(v.id)));
|
||||
lastSetRef.current = `${listId}:${idsKey(next)}`;
|
||||
// The table goes with its last row: a focus in it or in the band would fall to <body>, and the
|
||||
// next Tab start over at the top of the page. The list's "…" takes it.
|
||||
const held = document.activeElement;
|
||||
if (!next.length && (tableRef.current?.contains(held) || bandRef.current?.contains(held)))
|
||||
renameTarget.current?.focus();
|
||||
}
|
||||
// …and out of the CACHED list too, not just the rows. Anything that rewrites that cache entry
|
||||
// before the refetch lands handed the membership guard a list that still held the video — the
|
||||
// player's own progress checkpoint does exactly that when a removal steps it to the next item —
|
||||
// so the guard reset the order: the removed row came back and the undo history the rebase had
|
||||
// kept went with it. (A failure puts it back: `putBack`.)
|
||||
const before = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
|
||||
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
|
||||
old ? { ...old, items: old.items.filter((v) => !gone.has(v.id)) } : old,
|
||||
@@ -785,6 +764,36 @@ function PlaylistDetail() {
|
||||
}
|
||||
const titleOf = (rows: Video[], id: string | undefined) =>
|
||||
raw(rows.find((v) => v.id === id)?.title ?? "");
|
||||
// What an action on this one row hands a failure to tick again.
|
||||
const tickOf = (v: Video) => (selection.isSelected(v.id) ? [v.id] : []);
|
||||
// Where rows go back when their Undo comes: against the list as it is NOW, fetched fresh, and after
|
||||
// their neighbours in the STORED order they stood in, so neither a view sort on screen nor a change
|
||||
// in between shifts them (`reinsertAt`).
|
||||
const placesNow = (listId: number, storedBefore: string[], ids: string[]) =>
|
||||
qc
|
||||
.fetchQuery({
|
||||
queryKey: qk.playlist(listId),
|
||||
queryFn: () => api.playlist(listId),
|
||||
staleTime: 0,
|
||||
})
|
||||
.then((now) =>
|
||||
reinsertAt(
|
||||
storedBefore,
|
||||
ids,
|
||||
now.items.map((v) => v.id),
|
||||
),
|
||||
);
|
||||
// An Undo that could not bring everything back says so: the server leaves out a video it cannot put
|
||||
// back (gone on from the target, or from the catalog), and silence would read as all of them.
|
||||
function sayWhatCameBack(asked: number, added: number) {
|
||||
if (added >= asked) return;
|
||||
notify({
|
||||
level: "warning",
|
||||
message: added
|
||||
? msg("playlists.undoSomeBack", { count: added })
|
||||
: msg("playlists.undoNoneBack"),
|
||||
});
|
||||
}
|
||||
|
||||
/** Move rows into another list, in ONE request (see the endpoint). `ticked`: the ones the selection
|
||||
* held, which a failure ticks again. */
|
||||
@@ -828,21 +837,11 @@ function PlaylistDetail() {
|
||||
: msg("playlists.movedManyToast", { count: left.length, name }),
|
||||
action: {
|
||||
label: msg("common.undo"),
|
||||
// The places worked out against the list as it is NOW, as in `removeItem`. A video back in
|
||||
// the source by then stays where it is, in both lists.
|
||||
// A video back in the source by then stays where it is, in both lists. One gone on from the
|
||||
// target stays out: the server says how many came back.
|
||||
onClick: () =>
|
||||
void qc
|
||||
.fetchQuery({
|
||||
queryKey: qk.playlist(listId),
|
||||
queryFn: () => api.playlist(listId),
|
||||
staleTime: 0,
|
||||
})
|
||||
.then((now) => {
|
||||
const back = reinsertAt(
|
||||
storedBefore,
|
||||
left,
|
||||
now.items.map((v) => v.id),
|
||||
);
|
||||
void placesNow(listId, storedBefore, left)
|
||||
.then((back) => {
|
||||
const place = new Map(back.keys.map((id, i) => [id, back.positions[i]!]));
|
||||
const keep = (list: string[]) => list.filter((id) => place.has(id));
|
||||
const undoMoved = keep(moved);
|
||||
@@ -852,7 +851,7 @@ function PlaylistDetail() {
|
||||
moved: undoMoved,
|
||||
already_there: undoHeld,
|
||||
positions: [...undoMoved, ...undoHeld].map((id) => place.get(id)!),
|
||||
});
|
||||
}).then((r) => sayWhatCameBack(back.keys.length, r.added));
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
notifyActionError(
|
||||
@@ -865,81 +864,89 @@ function PlaylistDetail() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove rows in ONE request, with an Undo that puts them back at their places in one too. */
|
||||
async function removeRows(rows: Video[], ticked: readonly string[]) {
|
||||
if (selectedId == null || !rows.length) return;
|
||||
const listId = selectedId;
|
||||
/** Remove rows in ONE request, with an Undo that puts them back at their places in one too: an
|
||||
* append plus a whole-list reorder would land them at the end whenever the second call failed.
|
||||
* `ticked`: the ones the selection held, which a failure ticks again.
|
||||
* @returns whether the write went through. */
|
||||
async function removeRows(
|
||||
rows: Video[],
|
||||
ticked: readonly string[],
|
||||
listIdArg?: number | null,
|
||||
): Promise<boolean> {
|
||||
// Defaulted here, not as `listIdArg = selectedRef.current` in the signature: the React Compiler
|
||||
// cannot reorder a ref read in a default parameter and skipped the component for it.
|
||||
const listId = listIdArg === undefined ? selectedRef.current : listIdArg;
|
||||
if (listId == null || !rows.length) return false;
|
||||
const ids = rows.map((v) => v.id);
|
||||
const list = listRef(listId);
|
||||
const before = takeOut(listId, ids);
|
||||
const failTitle = msg(
|
||||
rows.length > 1 ? "playlists.removeManyFailed" : "playlists.removeFailed",
|
||||
);
|
||||
let removed: number | null = null;
|
||||
let removed: string[] | null = null;
|
||||
try {
|
||||
removed = (await removePlaylistItems(qc, list, ids)).removed;
|
||||
} catch (e) {
|
||||
// Said here, or a removal fails in silence (inside the full-screen player there is not even a row
|
||||
// snapping back to show it) — unless `req` already put it in its error dialog, which
|
||||
// `notifyActionError` checks.
|
||||
notifyActionError(e, failTitle);
|
||||
}
|
||||
refreshAfterEdit(ids);
|
||||
if (removed == null) {
|
||||
// …and the rows go back NOW, not when the resync lands: a removal that failed because the server
|
||||
// was unreachable has a resync that fails too.
|
||||
putBack(listId, before, ticked);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (removed === 0) {
|
||||
// A video someone else took out meanwhile is not this removal's to bring back.
|
||||
const went = removed;
|
||||
if (!went.length) {
|
||||
notify({ level: "warning", message: msg("playlists.removeNoneLeft") });
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
const storedBefore = before?.items.map((v) => v.id) ?? [];
|
||||
notify({
|
||||
level: "success",
|
||||
message:
|
||||
removed === 1 && rows.length === 1
|
||||
? msg("playlists.removedToast", { title: titleOf(rows, ids[0]) })
|
||||
: msg("playlists.removedManyToast", { count: removed }),
|
||||
went.length === 1 && rows.length === 1
|
||||
? msg("playlists.removedToast", { title: titleOf(rows, went[0]) })
|
||||
: msg("playlists.removedManyToast", { count: went.length }),
|
||||
action: {
|
||||
label: msg("common.undo"),
|
||||
onClick: () =>
|
||||
void qc
|
||||
.fetchQuery({
|
||||
queryKey: qk.playlist(listId),
|
||||
queryFn: () => api.playlist(listId),
|
||||
staleTime: 0,
|
||||
})
|
||||
.then((now) => {
|
||||
const back = reinsertAt(
|
||||
storedBefore,
|
||||
ids,
|
||||
now.items.map((v) => v.id),
|
||||
);
|
||||
void placesNow(listId, storedBefore, went)
|
||||
.then((back) => {
|
||||
if (back.keys.length)
|
||||
return restorePlaylistItems(qc, list, back.keys, back.positions);
|
||||
return restorePlaylistItems(qc, list, back.keys, back.positions).then((r) =>
|
||||
sayWhatCameBack(back.keys.length, r.added),
|
||||
);
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
notifyActionError(
|
||||
e,
|
||||
msg(
|
||||
ids.length > 1 ? "playlists.undoRemoveManyFailed" : "playlists.undoRemoveFailed",
|
||||
went.length > 1 ? "playlists.undoRemoveManyFailed" : "playlists.undoRemoveFailed",
|
||||
),
|
||||
),
|
||||
)
|
||||
.finally(() => refreshAfterEdit(ids)),
|
||||
.finally(() => refreshAfterEdit(went)),
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// The band goes when the selection does. A focus inside it would fall to <body>, and the next Tab
|
||||
// start over at the top of the page: the header's box takes it, or, when the action leaves no row
|
||||
// and so no box, the list's "…".
|
||||
function leaveBand(emptied = false) {
|
||||
if (!bandRef.current?.contains(document.activeElement)) return;
|
||||
(emptied ? renameTarget.current : selectAllRef.current)?.focus();
|
||||
// start over at the top of the page: the header's box takes it (`takeOut` moves it on when the
|
||||
// action leaves no row and so no box).
|
||||
function leaveBand() {
|
||||
if (bandRef.current?.contains(document.activeElement)) selectAllRef.current?.focus();
|
||||
}
|
||||
// The selection goes as the action starts: its rows are leaving, and a tick a filter hides would
|
||||
// come back later on a row the reader has forgotten about. A failure ticks the rows acted on again.
|
||||
function selectionRows() {
|
||||
const rows = shown.filter((v) => selection.isSelected(v.id));
|
||||
leaveBand(rows.length === items.length);
|
||||
leaveBand();
|
||||
selection.clear();
|
||||
return rows;
|
||||
}
|
||||
@@ -1039,6 +1046,12 @@ function PlaylistDetail() {
|
||||
// nothing to repeat: before the first move, or when the remembered list IS the one you are looking
|
||||
// at, which is not a move at all. Nothing to explain, so nothing to leave behind greyed out.
|
||||
const recentMoveTarget = moveTargets.find((p) => p.id === lastMoveTargetId);
|
||||
// `playlists` is empty until the rail's list is in, too: no targets only once it says so.
|
||||
const noTargetsNote = listQuery.data
|
||||
? t("playlists.moveNoTargets")
|
||||
: listQuery.isError
|
||||
? t("playlists.moveTargetsFailed")
|
||||
: t("playlists.moveTargetsLoading");
|
||||
const storedOrderEditable = canEdit && !viewActive;
|
||||
// While the rows must stay put the others make no way: no row is "over" anything.
|
||||
const dragCollision: CollisionDetection = (args) =>
|
||||
@@ -1059,6 +1072,10 @@ function PlaylistDetail() {
|
||||
carriedRef.current.length > 1
|
||||
? t("playlists.dragPickedMany", { count: carriedRef.current.length })
|
||||
: base.onDragStart(args),
|
||||
onDragCancel: (args: Parameters<typeof base.onDragCancel>[0]) =>
|
||||
carriedRef.current.length > 1
|
||||
? t("playlists.dragCancelledMany", { count: carriedRef.current.length })
|
||||
: base.onDragCancel(args),
|
||||
onDragMove: () => {
|
||||
const id = railDropTarget();
|
||||
return id == null
|
||||
@@ -1067,7 +1084,9 @@ function PlaylistDetail() {
|
||||
},
|
||||
onDragEnd: (args: Parameters<typeof base.onDragEnd>[0]) =>
|
||||
droppedOnRef.current == null
|
||||
? base.onDragEnd(args)
|
||||
? carriedRef.current.length > 1
|
||||
? t("playlists.dragCancelledMany", { count: carriedRef.current.length })
|
||||
: base.onDragEnd(args)
|
||||
: t("playlists.dragDroppedOnList", { name: listNameOf.current(droppedOnRef.current) }),
|
||||
},
|
||||
};
|
||||
@@ -1106,7 +1125,7 @@ function PlaylistDetail() {
|
||||
label: t("playlists.moveToRecent", { name: plName(recentMoveTarget) }),
|
||||
icon: <History className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
onSelect: () => void moveRows([ctxRow], recentMoveTarget),
|
||||
onSelect: () => void moveRows([ctxRow], recentMoveTarget, tickOf(ctxRow)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -1121,7 +1140,7 @@ function PlaylistDetail() {
|
||||
id: `move-${pl.id}`,
|
||||
label: plName(pl),
|
||||
icon: moveTargetIcon(pl),
|
||||
onSelect: () => void moveRows([ctxRow], pl),
|
||||
onSelect: () => void moveRows([ctxRow], pl, tickOf(ctxRow)),
|
||||
})),
|
||||
}
|
||||
: // Kept visible and disabled rather than dropped: an action that vanishes teaches
|
||||
@@ -1132,7 +1151,7 @@ function PlaylistDetail() {
|
||||
icon: <FolderInput className="w-4 h-4" />,
|
||||
separatorBefore: !recentMoveTarget,
|
||||
disabled: true,
|
||||
note: t("playlists.moveNoTargets"),
|
||||
note: noTargetsNote,
|
||||
onSelect: () => {},
|
||||
},
|
||||
// Same gate as the drag handle; the reason goes under the first of the four rows.
|
||||
@@ -1167,7 +1186,7 @@ function PlaylistDetail() {
|
||||
label: t("playlists.moveTo"),
|
||||
icon: <FolderInput className="w-4 h-4" />,
|
||||
disabled: true,
|
||||
note: t("playlists.moveNoTargets"),
|
||||
note: noTargetsNote,
|
||||
onSelect: () => {},
|
||||
},
|
||||
];
|
||||
@@ -1289,6 +1308,9 @@ function PlaylistDetail() {
|
||||
return (
|
||||
<>
|
||||
<div className="px-4 pb-4">
|
||||
<div role="status" className="sr-only">
|
||||
{selectionStatus}
|
||||
</div>
|
||||
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
|
||||
fixed band, so only the video list scrolls. */}
|
||||
<PageToolbar>
|
||||
@@ -1385,7 +1407,7 @@ function PlaylistDetail() {
|
||||
leaveBand();
|
||||
selection.clear();
|
||||
}}
|
||||
onClearView={viewActive ? clearView : undefined}
|
||||
onClearView={viewActive ? resetView : undefined}
|
||||
trailing={
|
||||
<UndoToolbar
|
||||
canUndo={order.canUndo}
|
||||
@@ -1425,7 +1447,7 @@ function PlaylistDetail() {
|
||||
{viewActive && (
|
||||
<Button
|
||||
variant="link-muted"
|
||||
onClick={clearView}
|
||||
onClick={resetView}
|
||||
className="text-xs underline underline-offset-2"
|
||||
>
|
||||
{t("playlists.clearView")}
|
||||
@@ -1477,7 +1499,7 @@ function PlaylistDetail() {
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={viewActive ? pointerSensorOnly : sensors}
|
||||
sensors={sensors}
|
||||
collisionDetection={dragCollision}
|
||||
// Not the list, under a drag that is over the rail: it would run away beside it.
|
||||
autoScroll={{ canScroll: () => !isOverRail() }}
|
||||
@@ -1501,7 +1523,7 @@ function PlaylistDetail() {
|
||||
{/* table-fixed, not auto: every column but the title has a declared width, so the
|
||||
title absorbs the rest and its `truncate` works. Auto layout sizes columns from
|
||||
min-content, and a nowrap title cell's min-content is the entire title. */}
|
||||
<table className="w-full table-fixed border-collapse text-sm">
|
||||
<table ref={tableRef} className="w-full table-fixed border-collapse text-sm">
|
||||
<ColumnHead
|
||||
columns={tableColumns}
|
||||
sort={viewSort}
|
||||
@@ -1539,8 +1561,15 @@ function PlaylistDetail() {
|
||||
return selection.clear();
|
||||
},
|
||||
removeSelected: () => {
|
||||
if (!selection.shownSelected.length) return false;
|
||||
removeSelected();
|
||||
if (!bandUp) return false;
|
||||
// Nothing happens to a row the reader cannot see, and neither to the
|
||||
// row under the keyboard, which no one selected.
|
||||
if (selection.shownSelected.length) removeSelected();
|
||||
else
|
||||
notify({
|
||||
level: "warning",
|
||||
message: msg("playlists.removeAllHidden"),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
}
|
||||
@@ -1571,12 +1600,13 @@ function PlaylistDetail() {
|
||||
)}
|
||||
</div>
|
||||
{/* On <body>: the page's scroller contains anything fixed, and the rail lies outside it.
|
||||
Above every layer (the tooltip's level), and no way back to fly: it drops or stays. Out
|
||||
of the pointer's way: lying under it, it kept the rail's tab from peeking open. */}
|
||||
{createPortal(
|
||||
Above every layer (the tooltip's level, by its token: dnd-kit's `zIndex` takes a number
|
||||
only, and its `style` comes after it), and no way back to fly: it drops or stays. Out of
|
||||
the pointer's way: lying under it, it kept the rail's tab from peeking open. */}
|
||||
<Overlay>
|
||||
<DragOverlay
|
||||
dropAnimation={null}
|
||||
zIndex={100}
|
||||
style={{ zIndex: "var(--z-index-tooltip)" }}
|
||||
className="pointer-events-none"
|
||||
modifiers={[beneathPointer]}
|
||||
>
|
||||
@@ -1589,9 +1619,8 @@ function PlaylistDetail() {
|
||||
: shown.find((v) => v.id === carried[0])?.title}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>,
|
||||
document.body,
|
||||
)}
|
||||
</DragOverlay>
|
||||
</Overlay>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import type { MouseEventHandler, ReactNode, Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, FolderInput, History, ListRestart, Square, X } from "lucide-react";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
@@ -8,7 +8,8 @@ import { Button } from "../../components/ui/Button";
|
||||
* it leaves alone. With only hidden ones the actions stay in place, off, and the way out stays on.
|
||||
* Below `xl` the buttons are glyphs named in their tooltips: in words the row wraps there, and the
|
||||
* first tick would push the list from under the second tap. On a phone the count is its number and
|
||||
* the undo pair stays out, for the same one row. */
|
||||
* the undo pair stays out, for the same one row. The page says the count out loud: this band goes
|
||||
* when the selection does, and a live region that leaves with its news announces nothing. */
|
||||
export function SelectionBand({
|
||||
ref,
|
||||
count,
|
||||
@@ -33,14 +34,14 @@ export function SelectionBand({
|
||||
onRemove: () => void;
|
||||
onClear: () => void;
|
||||
/** The view's reset, which the row this one replaces offers. */
|
||||
onClearView?: () => void;
|
||||
onClearView?: MouseEventHandler<HTMLButtonElement>;
|
||||
trailing: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const none = count === 0;
|
||||
return (
|
||||
<div ref={ref} className="flex items-center gap-1 mt-1 flex-wrap xl:gap-2">
|
||||
<span role="status" className="text-xs whitespace-nowrap">
|
||||
<span className="text-xs whitespace-nowrap">
|
||||
<span className="sr-only font-medium sm:not-sr-only">
|
||||
{t("playlists.selectedCount", { count })}
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { escapeHandled } from "../../lib/layerStack";
|
||||
import {
|
||||
isOverRail,
|
||||
railDropAreaAttrs,
|
||||
@@ -19,10 +20,10 @@ const runFrame = () => {
|
||||
now.forEach((f) => f(0));
|
||||
};
|
||||
|
||||
function rail() {
|
||||
function rail({ short = false } = {}) {
|
||||
const scroller = document.createElement("div");
|
||||
scroller.style.overflowY = "auto";
|
||||
Object.defineProperty(scroller, "scrollHeight", { value: 2000 });
|
||||
Object.defineProperty(scroller, "scrollHeight", { value: short ? 400 : 2000 });
|
||||
Object.defineProperty(scroller, "clientHeight", { value: 400 });
|
||||
scroller.getBoundingClientRect = () =>
|
||||
({ left: 0, right: 200, top: 100, bottom: 500, width: 200, height: 400 }) as DOMRect;
|
||||
@@ -158,6 +159,47 @@ describe("railDrop", () => {
|
||||
expect(scroller.scrollTop).toBeLessThan(down);
|
||||
});
|
||||
|
||||
it("reads the row a wheel scrolled under a pointer that fired nothing", () => {
|
||||
const { row } = rail();
|
||||
const r1 = row(1);
|
||||
const r2 = row(2);
|
||||
let lying = r1;
|
||||
under = () => [lying];
|
||||
const { result } = renderHook(() => useRailDropTarget());
|
||||
act(() => {
|
||||
startRailDrag(100, 300);
|
||||
});
|
||||
expect(result.current).toBe(1);
|
||||
lying = r2;
|
||||
act(() => {
|
||||
runFrame();
|
||||
});
|
||||
expect(result.current).toBe(2);
|
||||
expect(stopRailDrag()).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps the rail it found when there is nothing to scroll, rather than look again each frame", () => {
|
||||
rail({ short: true });
|
||||
startRailDrag(100, 300);
|
||||
const looked = vi.spyOn(window, "getComputedStyle");
|
||||
runFrame();
|
||||
runFrame();
|
||||
expect(looked).not.toHaveBeenCalled();
|
||||
looked.mockRestore();
|
||||
});
|
||||
|
||||
it("spends the drag's Esc, and no other", () => {
|
||||
const esc = () => {
|
||||
const e = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
|
||||
document.dispatchEvent(e);
|
||||
return e;
|
||||
};
|
||||
startRailDrag(100, 300);
|
||||
expect(escapeHandled(esc())).toBe(true);
|
||||
stopRailDrag();
|
||||
expect(escapeHandled(esc())).toBe(false);
|
||||
});
|
||||
|
||||
it("finds the rail that opened only during the drag, as an unpinned one peeks", () => {
|
||||
startRailDrag(100, 495);
|
||||
runFrame();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { markEscapeHandled } from "../../lib/layerStack";
|
||||
import { scrollParent } from "../../lib/listPaging";
|
||||
import { createStore } from "../../lib/store";
|
||||
|
||||
// Dragging a video onto a list in the rail. The rail and the page are separate React trees (the shell
|
||||
// mounts them side by side, each lazily), so no drag context reaches both: the page's drag reads the
|
||||
@@ -19,31 +21,21 @@ export const railDropAreaAttrs = { [RAIL_AREA]: "" };
|
||||
const EDGE = 48;
|
||||
const MAX_STEP = 12;
|
||||
|
||||
let over: number | null = null;
|
||||
const over = createStore<number | null>(null);
|
||||
let inRail = false;
|
||||
let point: { x: number; y: number } | null = null;
|
||||
let frame = 0;
|
||||
let scroller: HTMLElement | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function setOver(id: number | null) {
|
||||
if (id === over) return;
|
||||
over = id;
|
||||
listeners.forEach((l) => l());
|
||||
if (id !== over.get()) over.set(id);
|
||||
}
|
||||
|
||||
const subscribe = (l: () => void) => {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
};
|
||||
|
||||
/** The rail row under the drag right now. */
|
||||
export const useRailDropTarget = (): number | null => useSyncExternalStore(subscribe, () => over);
|
||||
export const useRailDropTarget = over.useValue;
|
||||
|
||||
/** The same, outside React. */
|
||||
export const railDropTarget = (): number | null => over;
|
||||
export const railDropTarget = over.get;
|
||||
|
||||
/** Whether the drag is over the rail at all, where the list's rows must not make way for it. */
|
||||
export const isOverRail = (): boolean => inRail;
|
||||
@@ -52,7 +44,7 @@ function readRail() {
|
||||
if (!point) return;
|
||||
let row: number | null = null;
|
||||
let area = false;
|
||||
// `elementsFromPoint`, because the drag image lies on top.
|
||||
// `elementsFromPoint`: a toast or a tooltip lying over the rail takes no drop, the row under it does.
|
||||
for (const el of document.elementsFromPoint(point.x, point.y)) {
|
||||
const hit = el.closest(`[${RAIL_ROW}]`);
|
||||
if (hit) row = Number(hit.getAttribute(RAIL_ROW));
|
||||
@@ -64,22 +56,13 @@ function readRail() {
|
||||
}
|
||||
|
||||
function railScroller(): HTMLElement | null {
|
||||
for (
|
||||
let el = document.querySelector<HTMLElement>(`[${RAIL_AREA}]`)?.parentElement;
|
||||
el;
|
||||
el = el.parentElement
|
||||
) {
|
||||
const { overflowY } = getComputedStyle(el);
|
||||
if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight)
|
||||
return el;
|
||||
}
|
||||
return null;
|
||||
const area = document.querySelector(`[${RAIL_AREA}]`);
|
||||
return area && scrollParent(area);
|
||||
}
|
||||
|
||||
/** With 45 lists the row wanted is usually out of sight, and dnd-kit scrolls only what holds the
|
||||
* dragged row: not the rail. */
|
||||
function scrollStep() {
|
||||
frame = requestAnimationFrame(scrollStep);
|
||||
function scrollAtEdge() {
|
||||
// Looked for again: an unpinned rail has no rows until the drag's own hover peeks it open.
|
||||
if (!scroller?.isConnected) scroller = railScroller();
|
||||
if (!point || !scroller) return;
|
||||
@@ -93,8 +76,14 @@ function scrollStep() {
|
||||
: fromBottom < EDGE
|
||||
? MAX_STEP * (1 - Math.max(fromBottom, 0) / EDGE)
|
||||
: 0;
|
||||
if (step === 0) return;
|
||||
scroller.scrollTop += step;
|
||||
if (step !== 0) scroller.scrollTop += step;
|
||||
}
|
||||
|
||||
function followStep() {
|
||||
frame = requestAnimationFrame(followStep);
|
||||
scrollAtEdge();
|
||||
// Every frame, not on a move alone: a wheel scrolls another row under a pointer that fires nothing,
|
||||
// and the drop would land on the row that scrolled away.
|
||||
readRail();
|
||||
}
|
||||
|
||||
@@ -103,20 +92,28 @@ const onPointerMove = (e: PointerEvent) => {
|
||||
readRail();
|
||||
};
|
||||
|
||||
// The drag's Esc is spent on the drag. dnd-kit's pointer sensor cancels on the document and prevents
|
||||
// no default, so without this the list's Esc clears the selection on the same key press.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") markEscapeHandled(e);
|
||||
};
|
||||
|
||||
/** Follows the pointer from where the drag began, until `stopRailDrag`. */
|
||||
export function startRailDrag(x: number, y: number): void {
|
||||
stopRailDrag();
|
||||
point = { x, y };
|
||||
scroller = railScroller();
|
||||
document.addEventListener("pointermove", onPointerMove, true);
|
||||
frame = requestAnimationFrame(scrollStep);
|
||||
window.addEventListener("keydown", onKeyDown, true);
|
||||
frame = requestAnimationFrame(followStep);
|
||||
readRail();
|
||||
}
|
||||
|
||||
/** Ends the following, and answers the row the drag was over. Safe without a drag. */
|
||||
export function stopRailDrag(): number | null {
|
||||
const last = over;
|
||||
const last = over.get();
|
||||
document.removeEventListener("pointermove", onPointerMove, true);
|
||||
window.removeEventListener("keydown", onKeyDown, true);
|
||||
cancelAnimationFrame(frame);
|
||||
point = null;
|
||||
scroller = null;
|
||||
|
||||
@@ -60,8 +60,9 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
proxy,
|
||||
// What an e2e run writes is no source. On Windows a file it is rewriting answers the watcher with
|
||||
// EBUSY, which takes the whole dev server down; each run also reloaded every open page.
|
||||
watch: { ignored: ["**/playwright-report/**", "**/test-results/**"] },
|
||||
// EBUSY, which takes the whole dev server down; each run also reloaded every open page. (Its
|
||||
// `test-results` is a default of vite's, and `ignored` adds to the defaults.)
|
||||
watch: { ignored: ["**/playwright-report/**", "**/e2e/.auth/**"] },
|
||||
},
|
||||
build: { outDir: "dist", rollupOptions: { input: ["index.html", "watch.html"] } },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user