From 4067be04a6bbb455cd87fa1394955a1090b9d9e0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 11:16:35 -0400 Subject: [PATCH] feat(web): PlaylistTrackRow component for M7 #352 slice 1 Variant of TrackRow specialised for playlist detail. Drag handle visible to owner only; remove button (X) visible to owner only; greyed-out + strikethrough when track_id is null (upstream track removed from library). Reuses the existing LikeButton and TrackMenu when the track is still alive. --- .../lib/components/PlaylistTrackRow.svelte | 97 ++++++++++++++ .../lib/components/PlaylistTrackRow.test.ts | 124 ++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 web/src/lib/components/PlaylistTrackRow.svelte create mode 100644 web/src/lib/components/PlaylistTrackRow.test.ts diff --git a/web/src/lib/components/PlaylistTrackRow.svelte b/web/src/lib/components/PlaylistTrackRow.svelte new file mode 100644 index 00000000..8697b5fb --- /dev/null +++ b/web/src/lib/components/PlaylistTrackRow.svelte @@ -0,0 +1,97 @@ + + +
onDragStart?.(row.position)} + ondragover={(e) => { e.preventDefault(); onDragOver?.(e, row.position); }} + ondrop={(e) => { e.preventDefault(); onDrop?.(e, row.position); }} +> + {#if isOwner} + + + + {/if} + + + + {format(row.duration_sec)} + + {#if !isUnavailable && liveTrack} + + + {/if} + + {#if isOwner} + + {/if} +
diff --git a/web/src/lib/components/PlaylistTrackRow.test.ts b/web/src/lib/components/PlaylistTrackRow.test.ts new file mode 100644 index 00000000..59ff43b7 --- /dev/null +++ b/web/src/lib/components/PlaylistTrackRow.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; +import type { PlaylistTrack } from '$lib/api/types'; + +// LikeButton + TrackMenu transitively pull in TanStack Query, the auth +// store, likes/quarantine APIs, the player store, and SvelteKit +// navigation. Mock all of them at module level so the component can be +// rendered in isolation. + +vi.mock('$lib/auth/store.svelte', () => ({ + user: { value: { id: 'u1', username: 'me', is_admin: false } } +})); + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => + readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, + isError: false + }), + likeEntity: vi.fn().mockResolvedValue(undefined), + unlikeEntity: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock('$lib/api/quarantine', () => ({ + flagTrack: vi.fn().mockResolvedValue({}), + unflagTrack: vi.fn().mockResolvedValue(undefined), + listMyQuarantine: vi.fn().mockResolvedValue([]), + createMyQuarantineQuery: () => + readable({ data: [], isPending: false, isError: false }) +})); + +vi.mock('$lib/api/admin/tracks', () => ({ + removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't-1' }) +})); + +vi.mock('$lib/player/store.svelte', () => ({ + playNext: vi.fn(), + enqueueTrack: vi.fn(), + playQueue: vi.fn(), + playRadio: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) }; +}); + +import PlaylistTrackRow from './PlaylistTrackRow.svelte'; + +const live: PlaylistTrack = { + position: 2, + track_id: 't-1', + album_id: 'a-1', + artist_id: 'ar-1', + title: 'Roygbiv', + artist_name: 'Boards of Canada', + album_title: 'MHTRTC', + duration_sec: 137, + stream_url: '/api/tracks/t-1/stream', + added_at: '' +}; + +const removed: PlaylistTrack = { + ...live, + track_id: null, + album_id: null, + artist_id: null, + stream_url: null +}; + +afterEach(() => vi.clearAllMocks()); + +describe('PlaylistTrackRow', () => { + test('renders title, artist, mm:ss duration', () => { + render(PlaylistTrackRow, { + props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } + }); + expect(screen.getByText('Roygbiv')).toBeInTheDocument(); + expect(screen.getByText('2:17')).toBeInTheDocument(); + }); + + test('shows drag handle and remove button for owner', () => { + render(PlaylistTrackRow, { + props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } + }); + expect(screen.getByLabelText('Drag handle')).toBeInTheDocument(); + expect( + screen.getByLabelText(/Remove Roygbiv from playlist/i) + ).toBeInTheDocument(); + }); + + test('hides drag handle and remove button for non-owner', () => { + render(PlaylistTrackRow, { + props: { row: live, isOwner: false, onRemove: vi.fn(), onPlay: vi.fn() } + }); + expect(screen.queryByLabelText('Drag handle')).not.toBeInTheDocument(); + expect( + screen.queryByLabelText(/Remove Roygbiv from playlist/i) + ).not.toBeInTheDocument(); + }); + + test('greyed-out + strikethrough when track_id is null', () => { + render(PlaylistTrackRow, { + props: { row: removed, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } + }); + const titleEl = screen.getByText('Roygbiv'); + expect(titleEl.className).toContain('line-through'); + }); + + test('clicking the title fires onPlay with the position', async () => { + const onPlay = vi.fn(); + render(PlaylistTrackRow, { + props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay } + }); + await fireEvent.click(screen.getByText('Roygbiv')); + expect(onPlay).toHaveBeenCalledWith(2); + }); +});