From 80a6861ded327f4f4c3bd7a7802172fd40f0182c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 11:22:25 -0400 Subject: [PATCH] feat(web): /playlists index page (M7 #352 slice 1) Replaces the placeholder route. Two sections: "Your playlists" (owned) and "From other users" (public). Inline create form in the header with Enter-to-submit / Esc-to-cancel. Empty-state copy when the operator has nothing yet. Routes to /playlists/{id} on card click via PlaylistCard. --- web/src/routes/playlists/+page.svelte | 120 ++++++++++++++++++++- web/src/routes/playlists/playlists.test.ts | 75 +++++++++++++ 2 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 web/src/routes/playlists/playlists.test.ts diff --git a/web/src/routes/playlists/+page.svelte b/web/src/routes/playlists/+page.svelte index c31cbf02..559233ae 100644 --- a/web/src/routes/playlists/+page.svelte +++ b/web/src/routes/playlists/+page.svelte @@ -1,2 +1,118 @@ -

Playlists

-

Playlists land in a subsequent plan.

+ + +
+
+

Playlists

+ +
+ + {#if creating} +
+ + {#if createError} +

{createError}

+ {/if} +
+ + +
+
+ {/if} + + {#if playlistsQuery?.isPending} +

Loading playlists…

+ {:else if playlistsQuery?.isError} + playlistsQuery.refetch()} /> + {:else if playlistsQuery?.data} +
+

Your playlists

+ {#if playlistsQuery.data.owned.length === 0} +

No playlists yet. Click "New playlist" to start one.

+ {:else} +
+ {#each playlistsQuery.data.owned as p (p.id)} + + {/each} +
+ {/if} +
+ + {#if playlistsQuery.data.public.length > 0} +
+

From other users

+
+ {#each playlistsQuery.data.public as p (p.id)} + + {/each} +
+
+ {/if} + {/if} +
diff --git a/web/src/routes/playlists/playlists.test.ts b/web/src/routes/playlists/playlists.test.ts new file mode 100644 index 00000000..73efcb61 --- /dev/null +++ b/web/src/routes/playlists/playlists.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import { mockQuery } from '../../test-utils/query'; +import type { Playlist } from '$lib/api/types'; + +vi.mock('$lib/api/playlists', () => ({ + createPlaylistsQuery: vi.fn(), + createPlaylist: vi.fn().mockResolvedValue({ id: 'p-new', name: 'X' }) +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) }) + }; +}); + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + +import PlaylistsPage from './+page.svelte'; +import { createPlaylistsQuery, createPlaylist } from '$lib/api/playlists'; + +const mockedCreatePlaylistsQuery = createPlaylistsQuery as ReturnType; +const mockedCreatePlaylist = createPlaylist as ReturnType; + +function p(over: Partial): Playlist { + return { + id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'A', + description: '', is_public: false, cover_url: '', track_count: 0, duration_sec: 0, + created_at: '', updated_at: '', ...over + }; +} + +describe('Playlists index page', () => { + test('renders own + public sections', () => { + mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({ + data: { + owned: [p({ id: 'p1', name: 'Mine' })], + public: [p({ id: 'p2', name: 'Theirs', user_id: 'u-other', owner_username: 'bob' })] + } + })); + render(PlaylistsPage); + expect(screen.getByText('Mine')).toBeInTheDocument(); + expect(screen.getByText('Theirs')).toBeInTheDocument(); + expect(screen.getByText(/your playlists/i)).toBeInTheDocument(); + expect(screen.getByText(/from other users/i)).toBeInTheDocument(); + }); + + test('empty-state copy when operator has no playlists', () => { + mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({ + data: { owned: [], public: [] } + })); + render(PlaylistsPage); + expect(screen.getByText(/no playlists yet/i)).toBeInTheDocument(); + }); + + test('hides "From other users" section when empty', () => { + mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({ + data: { owned: [p({ id: 'p1', name: 'Mine' })], public: [] } + })); + render(PlaylistsPage); + expect(screen.queryByText(/from other users/i)).not.toBeInTheDocument(); + }); + + test('Create button reveals inline form; submit creates and navigates', async () => { + mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({ data: { owned: [], public: [] } })); + render(PlaylistsPage); + await fireEvent.click(screen.getByRole('button', { name: /new playlist/i })); + const input = screen.getByPlaceholderText(/saturday morning/i); + await fireEvent.input(input, { target: { value: 'Test' } }); + await fireEvent.click(screen.getByRole('button', { name: /^create$/i })); + await waitFor(() => expect(mockedCreatePlaylist).toHaveBeenCalledWith({ name: 'Test' })); + }); +});