feat(playlists): act on the selected rows from the toolbar, and undo either
While rows are selected the toolbar's second row is the band: the count, the last target as one click, Move to, Remove, Clear selection. Both write in one request and undo back to the places; a video the target held before stays there. Delete takes the selection when there is one. A failure puts the rows back ticked. A row's own move goes through the same write, so it undoes too, and the single-row move route and service function are gone. A playlist row drops its grip below a 24rem table: the row menu keeps the moves.
This commit is contained in:
@@ -77,16 +77,58 @@ export const playlistsApi = {
|
||||
}),
|
||||
removeFromPlaylist: (id: number, videoId: string) =>
|
||||
req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }),
|
||||
// One request on purpose: add-then-remove from the client leaves the video in BOTH lists when
|
||||
// the second call fails. The server does it in one transaction.
|
||||
moveToPlaylist: (
|
||||
// One request on purpose: add-then-remove from the client leaves a video in BOTH lists when the
|
||||
// second call fails. The server does it in one transaction, and says which landed in the target
|
||||
// and which it already held: the Undo needs to tell them apart.
|
||||
moveManyToPlaylist: (
|
||||
id: number,
|
||||
videoId: string,
|
||||
videoIds: string[],
|
||||
targetPlaylistId: number,
|
||||
): Promise<{ ok: true; from_playlist_id: number; to_playlist_id: number; video_id: string }> =>
|
||||
req(`/api/playlists/${id}/items/${videoId}/move`, {
|
||||
): Promise<{
|
||||
ok: true;
|
||||
from_playlist_id: number;
|
||||
to_playlist_id: number;
|
||||
moved: string[];
|
||||
already_there: string[];
|
||||
}> =>
|
||||
req(`/api/playlists/${id}/items/move-bulk`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ target_playlist_id: targetPlaylistId }),
|
||||
body: JSON.stringify({ video_ids: videoIds, target_playlist_id: targetPlaylistId }),
|
||||
}),
|
||||
// The Undo of a move, back into `id`. `positions` pairs with `moved` + `alreadyThere`.
|
||||
undoPlaylistMove: (
|
||||
id: number,
|
||||
fromPlaylistId: number,
|
||||
moved: string[],
|
||||
alreadyThere: string[],
|
||||
positions: number[],
|
||||
): Promise<{ ok: true; playlist_id: number; added: number }> =>
|
||||
req(`/api/playlists/${id}/items/undo-move`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
from_playlist_id: fromPlaylistId,
|
||||
moved,
|
||||
already_there: alreadyThere,
|
||||
positions,
|
||||
}),
|
||||
}),
|
||||
removeManyFromPlaylist: (
|
||||
id: number,
|
||||
videoIds: string[],
|
||||
): Promise<{ ok: true; playlist_id: number; removed: number }> =>
|
||||
req(`/api/playlists/${id}/items/remove-bulk`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ video_ids: videoIds }),
|
||||
}),
|
||||
// The Undo of a removal, in one request; an index in the stored order per video.
|
||||
restoreToPlaylist: (
|
||||
id: number,
|
||||
videoIds: string[],
|
||||
positions: number[],
|
||||
): Promise<{ ok: true; playlist_id: number; added: number }> =>
|
||||
req(`/api/playlists/${id}/items/restore`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ video_ids: videoIds, positions }),
|
||||
}),
|
||||
reorderPlaylist: (id: number, videoIds: string[]) =>
|
||||
req(`/api/playlists/${id}/order`, {
|
||||
|
||||
@@ -10,9 +10,15 @@ import { useActiveScopes } from "./keymap";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function Rows({ selection }: { selection?: NavSelection }) {
|
||||
function Rows({
|
||||
selection,
|
||||
onDelete,
|
||||
}: {
|
||||
selection?: NavSelection;
|
||||
onDelete?: (index: number) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useListNav(ref, { enabled: true, selection });
|
||||
useListNav(ref, { enabled: true, selection, onDelete });
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
@@ -39,6 +45,7 @@ function press(key: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
||||
const selection = (cleared = true) => ({
|
||||
toggle: vi.fn<(index: number) => void>(),
|
||||
clear: vi.fn(() => cleared),
|
||||
removeSelected: vi.fn(() => false),
|
||||
});
|
||||
|
||||
describe("useListNav · selecting", () => {
|
||||
@@ -108,6 +115,25 @@ describe("useListNav · selecting", () => {
|
||||
expect(idle.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("has Delete go to the selection first, wherever the focus is", () => {
|
||||
const s = selection();
|
||||
s.removeSelected.mockReturnValue(true);
|
||||
const onDelete = vi.fn();
|
||||
render(<Rows selection={s} onDelete={onDelete} />);
|
||||
expect(press("Delete").defaultPrevented).toBe(true);
|
||||
expect(s.removeSelected).toHaveBeenCalledTimes(1);
|
||||
expect(onDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("has Delete go on to the walked row when there is no selection", () => {
|
||||
const s = selection();
|
||||
const onDelete = vi.fn();
|
||||
render(<Rows selection={s} onDelete={onDelete} />);
|
||||
press("j");
|
||||
press("Delete");
|
||||
expect(onDelete).toHaveBeenCalledExactlyOnceWith(0);
|
||||
});
|
||||
|
||||
it("leaves both keys alone in a list that takes no selection", () => {
|
||||
render(<Rows />);
|
||||
press("j");
|
||||
|
||||
@@ -45,6 +45,9 @@ interface NavList {
|
||||
export interface NavSelection {
|
||||
readonly toggle: (index: number) => void;
|
||||
readonly clear: () => boolean;
|
||||
/** Delete: takes the selection out, wherever the focus is. False when there is none, and Delete
|
||||
* goes on to the item under the keyboard. */
|
||||
readonly removeSelected: () => boolean;
|
||||
}
|
||||
|
||||
const lists = new Map<HTMLElement, NavList>();
|
||||
@@ -432,6 +435,12 @@ function onKey(e: KeyboardEvent): void {
|
||||
// On the item root, like Enter — and only in a list that registered a remover.
|
||||
remove: (ev) => {
|
||||
const cur = current();
|
||||
for (const list of ordered()) {
|
||||
if (!list.selection()?.removeSelected()) continue;
|
||||
ev.preventDefault();
|
||||
if (cur) refocusSlot(cur.list, cur.index, cur.item);
|
||||
return;
|
||||
}
|
||||
const remove = cur?.list.remove();
|
||||
if (!cur || !remove || document.activeElement !== cur.item) return;
|
||||
ev.preventDefault();
|
||||
|
||||
@@ -51,6 +51,9 @@ export interface Column<T> {
|
||||
// one beside a panel: a secondary column that gives its room back to the name (the owner,
|
||||
// 2026-09-27: at 1024 the channel name had 60 px while About took as much). Same `@container` rule.
|
||||
tabletHidden?: boolean;
|
||||
// …and one it leaves out while it is narrower than 24rem, a phone beside the nav rail: there the
|
||||
// title kept 44 px. Only for a column whose job has another way (the grip: the row menu's moves).
|
||||
phoneHidden?: boolean;
|
||||
}
|
||||
|
||||
// The classes that hide a column below its width (`narrowHidden` / `tabletHidden`), for its cells and,
|
||||
@@ -71,14 +74,20 @@ const HIDE_BELOW = {
|
||||
col: " hidden @5xl:table-column",
|
||||
cue: " @5xl:hidden",
|
||||
},
|
||||
phone: {
|
||||
cell: " hidden @sm:table-cell",
|
||||
col: " hidden @sm:table-column",
|
||||
cue: " @sm:hidden",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function columnHideClass(
|
||||
col: { narrowHidden?: boolean; tabletHidden?: boolean },
|
||||
col: { narrowHidden?: boolean; tabletHidden?: boolean; phoneHidden?: boolean },
|
||||
part: "cell" | "col" | "cue" = "cell",
|
||||
): string {
|
||||
if (col.narrowHidden) return HIDE_BELOW.narrow[part];
|
||||
if (col.tabletHidden) return HIDE_BELOW.tablet[part];
|
||||
if (col.phoneHidden) return HIDE_BELOW.phone[part];
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,22 @@ describe("useRowSelection", () => {
|
||||
expect(result.current.shownSelected).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("counts the selected rows a filter hides", () => {
|
||||
const { result, rerender } = setup();
|
||||
act(() => result.current.toggle("a"));
|
||||
act(() => result.current.toggle("c"));
|
||||
expect(result.current.hiddenCount).toBe(0);
|
||||
rerender({ all: ABCDE, shown: ["c", "d"], scope: 1 });
|
||||
expect(result.current.hiddenCount).toBe(1);
|
||||
});
|
||||
|
||||
it("ticks rows again on top of what is selected by then", () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.toggle("e"));
|
||||
act(() => result.current.select(["a", "b"]));
|
||||
expect(result.current.shownSelected).toEqual(["a", "b", "e"]);
|
||||
});
|
||||
|
||||
it("says whether clearing did anything", () => {
|
||||
const { result } = setup();
|
||||
let cleared = true;
|
||||
|
||||
@@ -59,8 +59,14 @@ export function useRowSelection(all: readonly string[], shown: readonly string[]
|
||||
return had;
|
||||
}
|
||||
|
||||
/** Tick these again: an action that took them off and failed. */
|
||||
function select(ids: readonly string[]) {
|
||||
setSelected((now) => new Set([...now, ...ids]));
|
||||
}
|
||||
|
||||
return {
|
||||
shownSelected,
|
||||
hiddenCount: selected.size - shownSelected.length,
|
||||
isSelected: (id: string) => selected.has(id),
|
||||
headerState:
|
||||
shownSelected.length === 0
|
||||
@@ -71,5 +77,6 @@ export function useRowSelection(all: readonly string[], shown: readonly string[]
|
||||
toggle,
|
||||
toggleAllShown,
|
||||
clear,
|
||||
select,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user