feat(web): AddToPlaylistMenu submenu wired into TrackMenu (M7 #352)
The "Add to playlist…" entry that #372 reserved as a disabled slot is now active. Submenu lists the operator's own playlists alphabetically; "New playlist…" toggles an inline create form that makes the playlist + appends the track in two API calls. TrackMenu's existing test asserts the entry is enabled and opens the submenu instead of the previous "is disabled with tooltip" check.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import { Plus } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { createPlaylistsQuery, appendTracks, createPlaylist } from '$lib/api/playlists';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { copyForCode } from '$lib/api/error-copy';
|
||||
import TrackMenuItem from './TrackMenuItem.svelte';
|
||||
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
|
||||
let {
|
||||
track,
|
||||
onClose
|
||||
}: {
|
||||
track: TrackRef;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const playlistsStore = $derived(createPlaylistsQuery());
|
||||
const playlistsQuery = $derived($playlistsStore);
|
||||
const ownPlaylists = $derived(
|
||||
(playlistsQuery?.data?.owned ?? []).slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||
);
|
||||
|
||||
let creating = $state(false);
|
||||
let newName = $state('');
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function add(playlistID: string) {
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
await appendTracks(playlistID, [track.id]);
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlist(playlistID) });
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||||
onClose();
|
||||
} catch (e: unknown) {
|
||||
const code = (e as { code?: string })?.code ?? 'unknown';
|
||||
error = copyForCode(code);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createAndAdd() {
|
||||
if (!newName.trim()) {
|
||||
error = 'Name is required';
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
const created = await createPlaylist({ name: newName.trim() });
|
||||
await appendTracks(created.id, [track.id]);
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||||
onClose();
|
||||
} catch (e: unknown) {
|
||||
const code = (e as { code?: string })?.code ?? 'unknown';
|
||||
error = copyForCode(code);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="menu"
|
||||
aria-label="Add to playlist"
|
||||
class="absolute right-full top-0 z-30 mr-1 w-56 rounded-md border border-border bg-surface p-1 shadow-lg"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{#each ownPlaylists as p (p.id)}
|
||||
<TrackMenuItem
|
||||
icon={Plus}
|
||||
label={p.name}
|
||||
onclick={() => add(p.id)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if ownPlaylists.length > 0}
|
||||
<TrackMenuDivider />
|
||||
{/if}
|
||||
|
||||
{#if !creating}
|
||||
<TrackMenuItem
|
||||
icon={Plus}
|
||||
label="New playlist…"
|
||||
onclick={() => { creating = true; }}
|
||||
/>
|
||||
{:else}
|
||||
<div class="p-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Playlist name"
|
||||
bind:value={newName}
|
||||
class="w-full rounded border border-border bg-background px-2 py-1 text-sm text-text-primary"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') createAndAdd(); }}
|
||||
/>
|
||||
<div class="mt-2 flex justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { creating = false; newName = ''; }}
|
||||
class="rounded px-2 py-1 text-xs text-text-muted hover:bg-surface-hover"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={createAndAdd}
|
||||
disabled={busy || !newName.trim()}
|
||||
class="rounded bg-action-secondary px-2 py-1 text-xs text-text-primary disabled:opacity-50"
|
||||
>
|
||||
Create & add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="px-2 py-1 text-xs text-action-destructive">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import { readable } from 'svelte/store';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
|
||||
const playlistsData = vi.hoisted(() => ({
|
||||
owned: [
|
||||
{
|
||||
id: 'p1',
|
||||
user_id: 'u-self',
|
||||
owner_username: 'me',
|
||||
name: 'B-list',
|
||||
description: '',
|
||||
is_public: false,
|
||||
cover_url: '',
|
||||
track_count: 3,
|
||||
duration_sec: 0,
|
||||
created_at: '',
|
||||
updated_at: ''
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
user_id: 'u-self',
|
||||
owner_username: 'me',
|
||||
name: 'A-list',
|
||||
description: '',
|
||||
is_public: false,
|
||||
cover_url: '',
|
||||
track_count: 5,
|
||||
duration_sec: 0,
|
||||
created_at: '',
|
||||
updated_at: ''
|
||||
}
|
||||
],
|
||||
public: [] as unknown[]
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) })
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/auth/store.svelte', () => ({
|
||||
user: { value: { id: 'u-self', username: 'me', is_admin: false } }
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/playlists', () => ({
|
||||
createPlaylistsQuery: () =>
|
||||
readable({ data: playlistsData, isPending: false, isError: false }),
|
||||
appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }),
|
||||
createPlaylist: vi.fn().mockResolvedValue({
|
||||
id: 'p3',
|
||||
user_id: 'u-self',
|
||||
owner_username: 'me',
|
||||
name: 'New',
|
||||
description: '',
|
||||
is_public: false,
|
||||
cover_url: '',
|
||||
track_count: 0,
|
||||
duration_sec: 0,
|
||||
created_at: '',
|
||||
updated_at: ''
|
||||
})
|
||||
}));
|
||||
|
||||
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
||||
|
||||
const track: TrackRef = {
|
||||
id: 't1',
|
||||
title: 'Roygbiv',
|
||||
album_id: 'a1',
|
||||
album_title: 'MHTRTC',
|
||||
artist_id: 'ar1',
|
||||
artist_name: 'BoC',
|
||||
duration_sec: 137,
|
||||
stream_url: '/api/tracks/t1/stream'
|
||||
};
|
||||
|
||||
describe('AddToPlaylistMenu', () => {
|
||||
test('lists own playlists alphabetically followed by "New playlist…"', () => {
|
||||
render(AddToPlaylistMenu, { props: { track, onClose: vi.fn() } });
|
||||
const items = screen.getAllByRole('menuitem');
|
||||
// First two should be the playlists in alpha order, then "New playlist…".
|
||||
expect(items[0].textContent).toContain('A-list');
|
||||
expect(items[1].textContent).toContain('B-list');
|
||||
expect(items[2].textContent).toContain('New playlist');
|
||||
});
|
||||
|
||||
test('clicking a playlist appends and closes', async () => {
|
||||
const onClose = vi.fn();
|
||||
const { appendTracks } = await import('$lib/api/playlists');
|
||||
render(AddToPlaylistMenu, { props: { track, onClose } });
|
||||
await fireEvent.click(screen.getByRole('menuitem', { name: /A-list/ }));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(appendTracks).toHaveBeenCalledWith('p2', ['t1']);
|
||||
});
|
||||
|
||||
test('"New playlist…" toggles inline create form', async () => {
|
||||
render(AddToPlaylistMenu, { props: { track, onClose: vi.fn() } });
|
||||
await fireEvent.click(screen.getByRole('menuitem', { name: /New playlist/ }));
|
||||
expect(screen.getByPlaceholderText(/playlist name/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Create & add/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import FlagPopover from './FlagPopover.svelte';
|
||||
import RemoveTrackPopover from './RemoveTrackPopover.svelte';
|
||||
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
||||
import TrackMenuItem from './TrackMenuItem.svelte';
|
||||
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
||||
import { user } from '$lib/auth/store.svelte';
|
||||
@@ -39,6 +40,7 @@
|
||||
let menuOpen = $state(false);
|
||||
let flagOpen = $state(false);
|
||||
let removeOpen = $state(false);
|
||||
let addOpen = $state(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -67,6 +69,7 @@
|
||||
menuOpen = false;
|
||||
flagOpen = false;
|
||||
removeOpen = false;
|
||||
addOpen = false;
|
||||
}
|
||||
|
||||
function onPlayNext() {
|
||||
@@ -122,7 +125,7 @@
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (!menuOpen && !flagOpen && !removeOpen) return;
|
||||
if (!menuOpen && !flagOpen && !removeOpen && !addOpen) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAll();
|
||||
@@ -130,7 +133,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={() => (menuOpen = false)} onkeydown={onKeydown} />
|
||||
<svelte:window onclick={() => { menuOpen = false; addOpen = false; }} onkeydown={onKeydown} />
|
||||
|
||||
<div class="relative inline-block">
|
||||
<button
|
||||
@@ -164,8 +167,7 @@
|
||||
<TrackMenuItem
|
||||
icon={ListMusic}
|
||||
label="Add to playlist…"
|
||||
disabled
|
||||
title="Coming with playlists"
|
||||
onclick={() => { addOpen = true; menuOpen = false; }}
|
||||
/>
|
||||
|
||||
<TrackMenuDivider />
|
||||
@@ -199,4 +201,8 @@
|
||||
{#if removeOpen}
|
||||
<RemoveTrackPopover {track} onClose={closeAll} />
|
||||
{/if}
|
||||
|
||||
{#if addOpen}
|
||||
<AddToPlaylistMenu {track} onClose={() => (addOpen = false)} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import { readable } from 'svelte/store';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
|
||||
@@ -37,6 +37,13 @@ vi.mock('$lib/api/admin/tracks', () => ({
|
||||
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' })
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/playlists', () => ({
|
||||
createPlaylistsQuery: () =>
|
||||
readable({ data: { owned: [], public: [] }, isPending: false, isError: false }),
|
||||
appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }),
|
||||
createPlaylist: vi.fn().mockResolvedValue({ id: 'p1', name: 'New' })
|
||||
}));
|
||||
|
||||
vi.mock('$lib/player/store.svelte', () => ({
|
||||
playNext: vi.fn(),
|
||||
enqueueTrack: vi.fn()
|
||||
@@ -100,12 +107,15 @@ describe('TrackMenu', () => {
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(8);
|
||||
});
|
||||
|
||||
test('"Add to playlist…" rendered as aria-disabled with tooltip', async () => {
|
||||
test('"Add to playlist…" is enabled and opens the AddToPlaylistMenu submenu', async () => {
|
||||
render(TrackMenu, { props: { track } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
||||
const item = screen.getByRole('menuitem', { name: /add to playlist/i });
|
||||
expect(item.getAttribute('aria-disabled')).toBe('true');
|
||||
expect(item.getAttribute('title')).toBe('Coming with playlists');
|
||||
expect(item.getAttribute('aria-disabled')).toBe('false');
|
||||
await fireEvent.click(item);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('menu', { name: /add to playlist/i })).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
test('"Play next" dispatches playNext(track)', async () => {
|
||||
|
||||
Reference in New Issue
Block a user