feat(web): replace / placeholder with real artists list

Uses createArtistsQuery (infinite), URL-driven sort dropdown, Load
more pagination, delayed skeleton, and shared error banner. Also
introduces the test-utils/query.ts helpers for subsequent page tests.
This commit is contained in:
2026-04-23 19:43:37 -04:00
parent f94bf26f02
commit 5174b093b9
3 changed files with 253 additions and 2 deletions
+83 -2
View File
@@ -1,2 +1,83 @@
<h1 class="text-2xl font-semibold">Library</h1>
<p class="mt-2 text-text-secondary">Library views land in a subsequent plan.</p>
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { createArtistsQuery, type ArtistSort } from '$lib/api/queries';
import ArtistRow from '$lib/components/ArtistRow.svelte';
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import { useDelayed } from '$lib/utils/useDelayed.svelte';
const sort: ArtistSort = $derived(
(page.url.searchParams.get('sort') as ArtistSort) === 'newest' ? 'newest' : 'alpha'
);
const queryStore = $derived(createArtistsQuery(sort));
const query = $derived($queryStore);
// Flattened artist list from all loaded pages.
const artists = $derived(
query.data?.pages?.flatMap((p) => p.items) ?? []
);
const total = $derived(query.data?.pages?.[0]?.total ?? 0);
const showSkeleton = useDelayed(() => query.isPending);
function onSortChange(e: Event) {
const value = (e.currentTarget as HTMLSelectElement).value as ArtistSort;
goto(`?sort=${value}`, { replaceState: true });
}
</script>
<div class="space-y-4">
<header class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold">Library</h1>
{#if !query.isPending && !query.isError}
<p class="text-sm text-text-secondary">
{total} {total === 1 ? 'artist' : 'artists'}
</p>
{/if}
</div>
<label class="flex items-center gap-2 text-sm">
<span class="text-text-secondary">Sort</span>
<select
value={sort}
onchange={onSortChange}
class="rounded border border-border bg-surface px-2 py-1"
>
<option value="alpha">AZ</option>
<option value="newest">Newest</option>
</select>
</label>
</header>
{#if query.isError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && artists.length === 0}
<LibrarySkeleton variant="list" count={12} />
{:else if !query.isPending && total === 0}
<p class="text-text-secondary">
No artists yet — scan a library folder via the server's config.
</p>
{:else}
<div>
{#each artists as a (a.id)}
<ArtistRow artist={a} />
{/each}
</div>
{#if query.hasNextPage}
<button
type="button"
class="w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
disabled={query.isFetchingNextPage}
onclick={() => query.fetchNextPage()}
>
{query.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
{:else if artists.length > 0}
<p class="py-2 text-center text-sm text-text-secondary">End of library</p>
{/if}
{/if}
</div>
+124
View File
@@ -0,0 +1,124 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { flushSync } from 'svelte';
import { mockInfiniteQuery } from '../test-utils/query';
import type { ArtistRef, Page } from '$lib/api/types';
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/') }));
vi.mock('$app/state', () => ({
page: { get url() { return state.pageUrl; } }
}));
vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
vi.mock('$lib/api/queries', () => ({
qk: { artists: (sort: string) => ['artists', { sort }] },
createArtistsQuery: vi.fn()
}));
import ArtistsPage from './+page.svelte';
import { createArtistsQuery } from '$lib/api/queries';
import { goto } from '$app/navigation';
function page<T>(items: T[], total: number, offset = 0, limit = 50): Page<T> {
return { items, total, offset, limit };
}
afterEach(() => {
vi.clearAllMocks();
state.pageUrl = new URL('http://localhost/');
});
describe('artists list page', () => {
test('renders a row per artist', () => {
const alice: ArtistRef = { id: 'a', name: 'Alice', album_count: 3 };
const bob: ArtistRef = { id: 'b', name: 'Bob', album_count: 1 };
(createArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page([alice, bob], 2)] })
);
render(ArtistsPage);
expect(screen.getByRole('link', { name: /Alice/ })).toHaveAttribute('href', '/artists/a');
expect(screen.getByRole('link', { name: /Bob/ })).toHaveAttribute('href', '/artists/b');
});
test('renders the total count from the first page', () => {
(createArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page<ArtistRef>([], 1247)] })
);
render(ArtistsPage);
expect(screen.getByText(/1247 artists/i)).toBeInTheDocument();
});
test('Load more button calls fetchNextPage when hasNextPage', async () => {
const fetchNextPage = vi.fn();
(createArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({
pages: [page<ArtistRef>([], 100)],
hasNextPage: true,
fetchNextPage
})
);
render(ArtistsPage);
await fireEvent.click(screen.getByRole('button', { name: /load more/i }));
expect(fetchNextPage).toHaveBeenCalledTimes(1);
});
test('"End of library" when hasNextPage is false and at least one page loaded', () => {
(createArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page<ArtistRef>([{ id: 'a', name: 'A', album_count: 1 }], 1)] })
);
render(ArtistsPage);
expect(screen.getByText(/end of library/i)).toBeInTheDocument();
});
test('empty total renders empty-library message', () => {
(createArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page<ArtistRef>([], 0)] })
);
render(ArtistsPage);
expect(screen.getByText(/no artists yet/i)).toBeInTheDocument();
});
test('changing the sort dropdown calls goto with ?sort=newest', async () => {
(createArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page<ArtistRef>([], 0)] })
);
render(ArtistsPage);
const select = screen.getByLabelText(/sort/i) as HTMLSelectElement;
await fireEvent.change(select, { target: { value: 'newest' } });
expect(goto).toHaveBeenCalledWith('?sort=newest', { replaceState: true });
});
test('pending state renders the list skeleton after the useDelayed window', () => {
(createArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ isPending: true })
);
vi.useFakeTimers();
try {
render(ArtistsPage);
vi.advanceTimersByTime(200);
flushSync(); // flush the $effect that reads delayed.value
expect(screen.getByTestId('skeleton-list')).toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
test('error state renders the error banner with retry', async () => {
const refetch = vi.fn();
(createArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({
isError: true,
error: { code: 'server_error', message: 'db down', status: 500 },
refetch
})
);
render(ArtistsPage);
expect(screen.getByRole('alert')).toHaveTextContent('db down');
await fireEvent.click(screen.getByRole('button', { name: /try again/i }));
expect(refetch).toHaveBeenCalledTimes(1);
});
});
+46
View File
@@ -0,0 +1,46 @@
import { readable } from 'svelte/store';
import type { Page } from '$lib/api/types';
// Synthetic infinite-query object shape matching what the SPA reads from
// @tanstack/svelte-query. Enough surface area for component tests;
// real behavior is covered by the TanStack library's own tests.
//
// Returns a Svelte readable store (matching CreateInfiniteQueryResult) so
// the component can subscribe with the $store rune.
export function mockInfiniteQuery<T>(opts: {
pages?: Page<T>[];
isPending?: boolean;
isError?: boolean;
error?: unknown;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
fetchNextPage?: () => void;
refetch?: () => void;
} = {}) {
return readable({
data: { pages: opts.pages ?? [] },
isPending: opts.isPending ?? false,
isError: opts.isError ?? false,
error: opts.error,
hasNextPage: opts.hasNextPage ?? false,
isFetchingNextPage: opts.isFetchingNextPage ?? false,
fetchNextPage: opts.fetchNextPage ?? (() => {}),
refetch: opts.refetch ?? (() => {})
});
}
export function mockQuery<T>(opts: {
data?: T;
isPending?: boolean;
isError?: boolean;
error?: unknown;
refetch?: () => void;
} = {}) {
return readable({
data: opts.data,
isPending: opts.isPending ?? false,
isError: opts.isError ?? false,
error: opts.error,
refetch: opts.refetch ?? (() => {})
});
}