diff --git a/web/src/lib/components/AddToPlaylistMenu.svelte b/web/src/lib/components/AddToPlaylistMenu.svelte new file mode 100644 index 00000000..c979bffb --- /dev/null +++ b/web/src/lib/components/AddToPlaylistMenu.svelte @@ -0,0 +1,124 @@ + + + diff --git a/web/src/lib/components/AddToPlaylistMenu.test.ts b/web/src/lib/components/AddToPlaylistMenu.test.ts new file mode 100644 index 00000000..84c3d6d0 --- /dev/null +++ b/web/src/lib/components/AddToPlaylistMenu.test.ts @@ -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; + 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(); + }); +}); diff --git a/web/src/lib/components/TrackMenu.svelte b/web/src/lib/components/TrackMenu.svelte index 315e6e1e..791d502a 100644 --- a/web/src/lib/components/TrackMenu.svelte +++ b/web/src/lib/components/TrackMenu.svelte @@ -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 @@ } - (menuOpen = false)} onkeydown={onKeydown} /> + { menuOpen = false; addOpen = false; }} onkeydown={onKeydown} />
diff --git a/web/src/lib/components/TrackMenu.test.ts b/web/src/lib/components/TrackMenu.test.ts index d7d80600..16eb7054 100644 --- a/web/src/lib/components/TrackMenu.test.ts +++ b/web/src/lib/components/TrackMenu.test.ts @@ -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 () => {