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