feat(playlists): a playlist's menu copies its name and its YouTube address

Every object's action list ends its middle group with Copy name / Copy link (E28 C1, the rows a
finger cannot select); the playlist's had neither.
This commit is contained in:
2026-09-27 15:04:47 +02:00
parent 6ffb5bd8c1
commit 5d1065c1bc
3 changed files with 31 additions and 9 deletions
@@ -146,6 +146,7 @@ describe("PlaylistsRail · the row's '…'", () => {
await vi.waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent)).toEqual([
"playlists.rename",
"common.copy.name",
"playlists.exportToYoutube",
"playlists.delete",
]),
@@ -102,7 +102,16 @@ describe("usePlaylistActions — a playlist's menu", () => {
it("lists a YouTube-linked list with unpushed edits in full", () => {
const items = rows({ ...linked, dirty: true });
expect(ids(items)).toEqual(["rename", "pin", "open-youtube", "push", "revert", "delete"]);
expect(ids(items)).toEqual([
"rename",
"copy-text",
"copy-link",
"pin",
"open-youtube",
"push",
"revert",
"delete",
]);
expect(items.find((i) => i.id === "push")!.label).toBe("playlists.pushToYoutube");
// One separator opens the YouTube group, one the delete.
expect(items.filter((i) => i.separatorBefore).map((i) => i.id)).toEqual([
@@ -113,7 +122,7 @@ describe("usePlaylistActions — a playlist's menu", () => {
it("offers a local list an export and nothing to open or revert", () => {
const items = rows({ id: 5, name: "Mine", kind: "user", yt_playlist_id: null, dirty: false });
expect(ids(items)).toEqual(["rename", "pin", "push", "delete"]);
expect(ids(items)).toEqual(["rename", "copy-text", "pin", "push", "delete"]);
expect(items.find((i) => i.id === "push")!.label).toBe("playlists.exportToYoutube");
});
@@ -121,6 +130,8 @@ describe("usePlaylistActions — a playlist's menu", () => {
h.canWrite = false;
expect(ids(rows({ ...linked, dirty: false }))).toEqual([
"rename",
"copy-text",
"copy-link",
"pin",
"open-youtube",
"delete",
@@ -135,7 +146,7 @@ describe("usePlaylistActions — a playlist's menu", () => {
yt_playlist_id: null,
dirty: false,
});
expect(ids(items)).toEqual(["rename", "pin", "delete"]);
expect(ids(items)).toEqual(["rename", "copy-text", "pin", "delete"]);
for (const id of ["rename", "delete"]) {
const it = items.find((i) => i.id === id)!;
expect(it.disabled).toBe(true);
@@ -143,6 +154,14 @@ describe("usePlaylistActions — a playlist's menu", () => {
}
});
// E28 review L6: every object's menu copies its name and link (the finger cannot select them);
// the playlist's did not.
it("copies the list's name, and a linked list's YouTube address", () => {
const items = rows(linked);
expect(items.find((i) => i.id === "copy-text")!.label).toBe("common.copy.name");
expect(items.find((i) => i.id === "copy-link")!.label).toBe("common.copy.link");
});
it("opens the list on YouTube inside the click, in a new tab", () => {
const open = vi.spyOn(window, "open").mockReturnValue(null);
const row = rows(linked).find((i) => i.id === "open-youtube")!;
@@ -12,6 +12,7 @@ import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
import { useMe } from "../lib/useMe";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { createStore } from "../lib/store";
import { useCopyItems } from "./copyItems";
// A playlist's ONE action list (E28 C1) and the mutations behind it. Two entry points open it — the
// rail row (right-click, long-press, its "…") and the list page header's "…" — so it lives here,
@@ -39,6 +40,7 @@ export function usePlaylistActions() {
const choose = useChoice();
const confirm = useConfirm();
const canWrite = useMe().can_write;
const copy = useCopyItems();
const { selectedId } = usePlaylists();
const { setSelectedId } = usePlaylistsActions();
const { pushing, reverting } = inFlight.use();
@@ -227,6 +229,9 @@ export function usePlaylistActions() {
? t("playlists.builtinLocked", { name: t("playlists.watchLater") })
: undefined;
const busy = (id: number | null) => id === pl.id;
const ytUrl = pl.yt_playlist_id
? `https://www.youtube.com/playlist?list=${pl.yt_playlist_id}`
: null;
const youtube: ActionMenuItem[] = [
...(pl.yt_playlist_id
? [
@@ -236,12 +241,7 @@ export function usePlaylistActions() {
icon: <ExternalLink className="w-4 h-4" />,
// A new tab only opens inside the click (a popup blocker refuses one a task later).
inGesture: true,
onSelect: () =>
void window.open(
`https://www.youtube.com/playlist?list=${pl.yt_playlist_id}`,
"_blank",
"noopener,noreferrer",
),
onSelect: () => void window.open(ytUrl!, "_blank", "noopener,noreferrer"),
},
]
: []),
@@ -284,6 +284,8 @@ export function usePlaylistActions() {
note: lockedNote,
onSelect: onRename,
},
// Its name and, when linked, its YouTube address — every object's menu copies them (E28 L6).
...copy(playlistName(pl, t), ytUrl, "name"),
...pin,
...youtube.map((it, i) => ({ ...it, separatorBefore: i === 0 })),
{