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(() => await vi.waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent)).toEqual([ expect(screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent)).toEqual([
"playlists.rename", "playlists.rename",
"common.copy.name",
"playlists.exportToYoutube", "playlists.exportToYoutube",
"playlists.delete", "playlists.delete",
]), ]),
@@ -102,7 +102,16 @@ describe("usePlaylistActions — a playlist's menu", () => {
it("lists a YouTube-linked list with unpushed edits in full", () => { it("lists a YouTube-linked list with unpushed edits in full", () => {
const items = rows({ ...linked, dirty: true }); 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"); expect(items.find((i) => i.id === "push")!.label).toBe("playlists.pushToYoutube");
// One separator opens the YouTube group, one the delete. // One separator opens the YouTube group, one the delete.
expect(items.filter((i) => i.separatorBefore).map((i) => i.id)).toEqual([ 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", () => { 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 }); 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"); expect(items.find((i) => i.id === "push")!.label).toBe("playlists.exportToYoutube");
}); });
@@ -121,6 +130,8 @@ describe("usePlaylistActions — a playlist's menu", () => {
h.canWrite = false; h.canWrite = false;
expect(ids(rows({ ...linked, dirty: false }))).toEqual([ expect(ids(rows({ ...linked, dirty: false }))).toEqual([
"rename", "rename",
"copy-text",
"copy-link",
"pin", "pin",
"open-youtube", "open-youtube",
"delete", "delete",
@@ -135,7 +146,7 @@ describe("usePlaylistActions — a playlist's menu", () => {
yt_playlist_id: null, yt_playlist_id: null,
dirty: false, dirty: false,
}); });
expect(ids(items)).toEqual(["rename", "pin", "delete"]); expect(ids(items)).toEqual(["rename", "copy-text", "pin", "delete"]);
for (const id of ["rename", "delete"]) { for (const id of ["rename", "delete"]) {
const it = items.find((i) => i.id === id)!; const it = items.find((i) => i.id === id)!;
expect(it.disabled).toBe(true); 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", () => { it("opens the list on YouTube inside the click, in a new tab", () => {
const open = vi.spyOn(window, "open").mockReturnValue(null); const open = vi.spyOn(window, "open").mockReturnValue(null);
const row = rows(linked).find((i) => i.id === "open-youtube")!; 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 { useMe } from "../lib/useMe";
import { type ActionMenuItem } from "./ui/ActionMenu"; import { type ActionMenuItem } from "./ui/ActionMenu";
import { createStore } from "../lib/store"; 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 // 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, // 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 choose = useChoice();
const confirm = useConfirm(); const confirm = useConfirm();
const canWrite = useMe().can_write; const canWrite = useMe().can_write;
const copy = useCopyItems();
const { selectedId } = usePlaylists(); const { selectedId } = usePlaylists();
const { setSelectedId } = usePlaylistsActions(); const { setSelectedId } = usePlaylistsActions();
const { pushing, reverting } = inFlight.use(); const { pushing, reverting } = inFlight.use();
@@ -227,6 +229,9 @@ export function usePlaylistActions() {
? t("playlists.builtinLocked", { name: t("playlists.watchLater") }) ? t("playlists.builtinLocked", { name: t("playlists.watchLater") })
: undefined; : undefined;
const busy = (id: number | null) => id === pl.id; 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[] = [ const youtube: ActionMenuItem[] = [
...(pl.yt_playlist_id ...(pl.yt_playlist_id
? [ ? [
@@ -236,12 +241,7 @@ export function usePlaylistActions() {
icon: <ExternalLink className="w-4 h-4" />, icon: <ExternalLink className="w-4 h-4" />,
// A new tab only opens inside the click (a popup blocker refuses one a task later). // A new tab only opens inside the click (a popup blocker refuses one a task later).
inGesture: true, inGesture: true,
onSelect: () => onSelect: () => void window.open(ytUrl!, "_blank", "noopener,noreferrer"),
void window.open(
`https://www.youtube.com/playlist?list=${pl.yt_playlist_id}`,
"_blank",
"noopener,noreferrer",
),
}, },
] ]
: []), : []),
@@ -284,6 +284,8 @@ export function usePlaylistActions() {
note: lockedNote, note: lockedNote,
onSelect: onRename, 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, ...pin,
...youtube.map((it, i) => ({ ...it, separatorBefore: i === 0 })), ...youtube.map((it, i) => ({ ...it, separatorBefore: i === 0 })),
{ {