feat(web): every paged list loads as you scroll; Liked becomes tabs (#5416)
release / go (push) Successful in 1m53s
release / govulncheck (push) Successful in 49s
release / web (push) Successful in 1m50s
release / android (push) Successful in 6m44s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 21m41s
release / go (push) Successful in 1m53s
release / govulncheck (push) Successful in 49s
release / web (push) Successful in 1m50s
release / android (push) Successful in 6m44s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 21m41s
Preference 172: no "Load more" buttons. Eight remained, on Liked (three sections), the three Search overflow pages, Genres and Years. - ListContinuation: the one bottom-of-list block. It loads the next page ahead of the reader, announces "Loading more…" through aria-live, shows an optional end line, and on a failed page shows the error with Try again, dropping the sentinel so a dead endpoint isn't re-hit on every scroll. Used on all ten paged lists, including the four that already autoloaded. - A failed next page no longer replaces the list. TanStack sets isError for it, so the page-level error branches now apply only to a failed first load. Genres and Years did the same with their own loader; a later page's failure now keeps the grid. - Liked: Artists | Albums | Tracks tabs (operator's choice). Stacked, a long Artists list loading as you scroll would bury the other two. It opens on the first tab that has likes. - TabStrip: the in-page tab strip, now shared by Liked, Playback errors and Requests. - test-utils/intersectionObserver: a stand-in so tests can scroll to the end. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
// The bottom of every paged list (#5416, preference 172): the next page
|
||||
// loads on its own as the reader nears the end, with no "Load more"
|
||||
// button. What the button used to give for free is kept here:
|
||||
//
|
||||
// - In flight: a "Loading more…" line, announced through aria-live, so the
|
||||
// list doesn't look finished while it isn't.
|
||||
// - Finished: an optional quiet end line (`endLabel`).
|
||||
// - Failed: the error and a Try again button. The sentinel is removed while
|
||||
// a page has failed, so a dead endpoint isn't re-requested on every scroll.
|
||||
import InfiniteScrollSentinel from './InfiniteScrollSentinel.svelte';
|
||||
|
||||
let {
|
||||
hasMore,
|
||||
loading,
|
||||
failed = false,
|
||||
onMore,
|
||||
endLabel
|
||||
}: {
|
||||
/** Whether another page exists. */
|
||||
hasMore: boolean;
|
||||
/** A next page is in flight. */
|
||||
loading: boolean;
|
||||
/** The last next-page fetch failed. */
|
||||
failed?: boolean;
|
||||
/** Fetch the next page (TanStack's fetchNextPage, or a page's own loader). */
|
||||
onMore: () => void;
|
||||
/** Shown once the list is complete, e.g. "End of history". */
|
||||
endLabel?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if failed}
|
||||
<div role="alert" class="flex items-center justify-center gap-2 py-2 text-sm text-error-fg">
|
||||
Couldn't load more.
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-2 py-0.5 text-text-primary hover:bg-surface-hover
|
||||
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
|
||||
onclick={onMore}>Try again</button
|
||||
>
|
||||
</div>
|
||||
{:else if hasMore}
|
||||
<InfiniteScrollSentinel enabled={!loading} onIntersect={onMore} />
|
||||
{/if}
|
||||
|
||||
<p class="py-2 text-center text-sm text-text-secondary empty:hidden" aria-live="polite">
|
||||
{#if !failed && hasMore && loading}Loading more…{:else if !hasMore && endLabel}{endLabel}{/if}
|
||||
</p>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import ListContinuation from './ListContinuation.svelte';
|
||||
|
||||
// InfiniteScrollSentinel renders <div aria-hidden="true" class="h-px">.
|
||||
const SENTINEL = 'div[aria-hidden="true"].h-px';
|
||||
|
||||
describe('ListContinuation', () => {
|
||||
test('more to come: a sentinel and no button', () => {
|
||||
const { container } = render(ListContinuation, {
|
||||
props: { hasMore: true, loading: false, onMore: vi.fn() }
|
||||
});
|
||||
expect(container.querySelector(SENTINEL)).not.toBeNull();
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('in flight: announces loading', () => {
|
||||
render(ListContinuation, { props: { hasMore: true, loading: true, onMore: vi.fn() } });
|
||||
const status = screen.getByText('Loading more…');
|
||||
expect(status).toHaveAttribute('aria-live', 'polite');
|
||||
});
|
||||
|
||||
test('finished: the end line, no sentinel', () => {
|
||||
const { container } = render(ListContinuation, {
|
||||
props: { hasMore: false, loading: false, onMore: vi.fn(), endLabel: 'End of history' }
|
||||
});
|
||||
expect(screen.getByText('End of history')).toBeInTheDocument();
|
||||
expect(container.querySelector(SENTINEL)).toBeNull();
|
||||
});
|
||||
|
||||
// A failed page must not be re-fetched by the observer on every scroll:
|
||||
// the sentinel goes away and the reader retries by hand.
|
||||
test('failed: an error with Try again, and no sentinel', async () => {
|
||||
const onMore = vi.fn();
|
||||
const { container } = render(ListContinuation, {
|
||||
props: { hasMore: true, loading: false, failed: true, onMore }
|
||||
});
|
||||
expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load more.");
|
||||
expect(container.querySelector(SENTINEL)).toBeNull();
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
||||
expect(onMore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts" generics="Id extends string">
|
||||
// In-page tabs: one strip of buttons switching what a single page shows
|
||||
// (#5416). The same look as AdminTabs and DiscoverTabs, which are route
|
||||
// navigation and so stay links; this is for state inside one page, such as
|
||||
// Liked's Artists | Albums | Tracks or a queue's status filter.
|
||||
type Item = {
|
||||
id: Id;
|
||||
label: string;
|
||||
/** Shown as a pill after the label. Omit for no pill. */
|
||||
count?: number;
|
||||
};
|
||||
|
||||
let {
|
||||
items,
|
||||
value = $bindable(),
|
||||
ariaLabel
|
||||
}: {
|
||||
items: Item[];
|
||||
value: Id;
|
||||
ariaLabel: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div role="tablist" aria-label={ariaLabel} class="flex gap-2 border-b border-border">
|
||||
{#each items as item (item.id)}
|
||||
{@const active = value === item.id}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
class="border-b-2 px-3 py-2 text-sm transition-colors {active
|
||||
? 'border-accent text-text-primary'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary'}"
|
||||
onclick={() => (value = item.id)}
|
||||
>
|
||||
{item.label}
|
||||
{#if item.count !== undefined}
|
||||
<span
|
||||
class="ml-1.5 inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
|
||||
data-testid="tab-count"
|
||||
>
|
||||
{item.count}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import TabStrip from './TabStrip.svelte';
|
||||
|
||||
const items = [
|
||||
{ id: 'artists', label: 'Artists', count: 3 },
|
||||
{ id: 'albums', label: 'Albums' }
|
||||
];
|
||||
|
||||
describe('TabStrip', () => {
|
||||
test('a tablist with the selected tab marked, and counts where given', () => {
|
||||
render(TabStrip, { props: { items, value: 'artists', ariaLabel: 'Liked' } });
|
||||
expect(screen.getByRole('tablist', { name: 'Liked' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /artists/i })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('tab', { name: /albums/i })).toHaveAttribute('aria-selected', 'false');
|
||||
expect(screen.getAllByTestId('tab-count').map((c) => c.textContent?.trim())).toEqual(['3']);
|
||||
});
|
||||
|
||||
test('clicking a tab selects it', async () => {
|
||||
render(TabStrip, { props: { items, value: 'artists', ariaLabel: 'Liked' } });
|
||||
await fireEvent.click(screen.getByRole('tab', { name: /albums/i }));
|
||||
expect(screen.getByRole('tab', { name: /albums/i })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('tab', { name: /artists/i })).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import Modal from '#lib/components/Modal.svelte';
|
||||
import TabStrip from '#lib/components/TabStrip.svelte';
|
||||
import type { AdminPlaybackError, PlaybackErrorResolution } from '#lib/api/types.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
|
||||
@@ -175,26 +176,14 @@
|
||||
|
||||
<!-- Tab toggle. Resolved / unresolved are two separate query keys so
|
||||
the cache holds both lists without re-fetching when you flip. -->
|
||||
<div role="tablist" aria-label="Resolution status" class="flex gap-2 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={!resolved}
|
||||
onclick={() => { resolved = false; }}
|
||||
class="border-b-2 px-3 py-2 text-sm transition-colors {!resolved
|
||||
? 'border-accent text-text-primary'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary'}"
|
||||
>Unresolved</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={resolved}
|
||||
onclick={() => { resolved = true; }}
|
||||
class="border-b-2 px-3 py-2 text-sm transition-colors {resolved
|
||||
? 'border-accent text-text-primary'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary'}"
|
||||
>Resolved</button>
|
||||
</div>
|
||||
<TabStrip
|
||||
ariaLabel="Resolution status"
|
||||
bind:value={() => (resolved ? 'resolved' : 'unresolved'), (v) => (resolved = v === 'resolved')}
|
||||
items={[
|
||||
{ id: 'unresolved', label: 'Unresolved' },
|
||||
{ id: 'resolved', label: 'Resolved' }
|
||||
]}
|
||||
/>
|
||||
|
||||
{#if query.isError}
|
||||
<p class="text-error-fg">Couldn't load: {errMessage(query.error)}</p>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Disc3, Album, Music2, Check, X, SlidersHorizontal } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import RowActionsMenu, { type RowAction } from '#lib/components/RowActionsMenu.svelte';
|
||||
import TabStrip from '#lib/components/TabStrip.svelte';
|
||||
import {
|
||||
createAdminRequestsQuery,
|
||||
createQualityProfilesQuery,
|
||||
@@ -166,34 +167,15 @@
|
||||
<p class="text-text-secondary">Approve or set aside what users have asked for.</p>
|
||||
</header>
|
||||
|
||||
<nav aria-label="Request status filters" class="border-b border-border">
|
||||
<ul class="flex gap-2">
|
||||
{#each tabs as tab (tab.status)}
|
||||
{@const isActive = activeStatus === tab.status}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
class="border-b-2 px-3 py-2 text-sm transition-colors {isActive
|
||||
? 'border-accent text-text-primary'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary'}"
|
||||
onclick={() => (activeStatus = tab.status)}
|
||||
>
|
||||
{tab.label}
|
||||
{#if isActive}
|
||||
<span
|
||||
class="ml-1.5 inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
|
||||
data-testid="active-tab-count"
|
||||
>
|
||||
{rows.length}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
<TabStrip
|
||||
ariaLabel="Request status filters"
|
||||
bind:value={activeStatus}
|
||||
items={tabs.map((tab) => ({
|
||||
id: tab.status,
|
||||
label: tab.label,
|
||||
count: activeStatus === tab.status ? rows.length : undefined
|
||||
}))}
|
||||
/>
|
||||
|
||||
{#if query.isPending}
|
||||
<p class="text-text-secondary">Reading the queue…</p>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { createSuspectSourcesQuery } from '#lib/api/admin.js';
|
||||
import { coverUrl } from '#lib/media/covers.js';
|
||||
import { formatDuration } from '#lib/media/duration.js';
|
||||
import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte';
|
||||
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
||||
import type { AdminSuspectGroup, AdminSuspectTrack } from '#lib/api/types.js';
|
||||
|
||||
// Files whose names say they were ripped from a video (#5410): "(Official
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
{#if query.isPending}
|
||||
<p class="text-text-secondary">Reading file names…</p>
|
||||
{:else if query.isError}
|
||||
{:else if query.isError && !query.isFetchNextPageError}
|
||||
<p class="text-error-fg">Couldn't load the suspect-sources list.</p>
|
||||
{:else if groups.length === 0}
|
||||
<div class="rounded-lg border border-border bg-surface p-6 text-center">
|
||||
@@ -150,14 +150,11 @@
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if query.hasNextPage}
|
||||
<InfiniteScrollSentinel
|
||||
enabled={!query.isFetchingNextPage}
|
||||
onIntersect={() => query.fetchNextPage()}
|
||||
/>
|
||||
{#if query.isFetchingNextPage}
|
||||
<p class="py-2 text-center text-sm text-text-secondary">Loading more…</p>
|
||||
{/if}
|
||||
{/if}
|
||||
<ListContinuation
|
||||
hasMore={!!query.hasNextPage}
|
||||
loading={query.isFetchingNextPage}
|
||||
failed={query.isFetchNextPageError}
|
||||
onMore={() => query.fetchNextPage()}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import AlbumCard from '#lib/components/AlbumCard.svelte';
|
||||
import AlphabeticalGrid from '#lib/components/AlphabeticalGrid.svelte';
|
||||
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
|
||||
import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte';
|
||||
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
||||
import QuickFilter from '#lib/components/QuickFilter.svelte';
|
||||
import EmptyState from '#lib/components/EmptyState.svelte';
|
||||
import { useDelayed } from '#lib/utils/useDelayed.svelte.js';
|
||||
@@ -32,7 +32,7 @@
|
||||
<header class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="font-display text-2xl font-medium text-text-primary">Albums</h1>
|
||||
{#if !query.isPending && !query.isError}
|
||||
{#if !query.isPending && !(query.isError && !query.isFetchNextPageError)}
|
||||
<p class="text-sm text-text-secondary">
|
||||
{total} {total === 1 ? 'album' : 'albums'}
|
||||
</p>
|
||||
@@ -43,7 +43,7 @@
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if query.isError}
|
||||
{#if query.isError && !query.isFetchNextPageError}
|
||||
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
|
||||
{:else if showSkeleton.value && albums.length === 0}
|
||||
<p class="text-text-secondary">Loading…</p>
|
||||
@@ -81,16 +81,12 @@
|
||||
{/snippet}
|
||||
</AlphabeticalGrid>
|
||||
|
||||
{#if query.hasNextPage}
|
||||
<InfiniteScrollSentinel
|
||||
enabled={!query.isFetchingNextPage}
|
||||
onIntersect={() => query.fetchNextPage()}
|
||||
/>
|
||||
{#if query.isFetchingNextPage}
|
||||
<p class="py-2 text-center text-sm text-text-secondary">Loading more…</p>
|
||||
{/if}
|
||||
{:else if albums.length > 0}
|
||||
<p class="py-2 text-center text-sm text-text-secondary">End of library</p>
|
||||
{/if}
|
||||
<ListContinuation
|
||||
hasMore={!!query.hasNextPage}
|
||||
loading={query.isFetchingNextPage}
|
||||
failed={query.isFetchNextPageError}
|
||||
onMore={() => query.fetchNextPage()}
|
||||
endLabel="End of library"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import ArtistCard from '#lib/components/ArtistCard.svelte';
|
||||
import AlphabeticalGrid from '#lib/components/AlphabeticalGrid.svelte';
|
||||
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
|
||||
import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte';
|
||||
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
||||
import QuickFilter from '#lib/components/QuickFilter.svelte';
|
||||
import EmptyState from '#lib/components/EmptyState.svelte';
|
||||
import { useDelayed } from '#lib/utils/useDelayed.svelte.js';
|
||||
@@ -30,7 +30,7 @@
|
||||
<header class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="font-display text-2xl font-medium text-text-primary">Artists</h1>
|
||||
{#if !query.isPending && !query.isError}
|
||||
{#if !query.isPending && !(query.isError && !query.isFetchNextPageError)}
|
||||
<p class="text-sm text-text-secondary">
|
||||
{total} {total === 1 ? 'artist' : 'artists'}
|
||||
</p>
|
||||
@@ -41,7 +41,7 @@
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if query.isError}
|
||||
{#if query.isError && !query.isFetchNextPageError}
|
||||
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
|
||||
{:else if showSkeleton.value && artists.length === 0}
|
||||
<p class="text-text-secondary">Loading…</p>
|
||||
@@ -79,16 +79,12 @@
|
||||
{/snippet}
|
||||
</AlphabeticalGrid>
|
||||
|
||||
{#if query.hasNextPage}
|
||||
<InfiniteScrollSentinel
|
||||
enabled={!query.isFetchingNextPage}
|
||||
onIntersect={() => query.fetchNextPage()}
|
||||
/>
|
||||
{#if query.isFetchingNextPage}
|
||||
<p class="py-2 text-center text-sm text-text-secondary">Loading more…</p>
|
||||
{/if}
|
||||
{:else if artists.length > 0}
|
||||
<p class="py-2 text-center text-sm text-text-secondary">End of library</p>
|
||||
{/if}
|
||||
<ListContinuation
|
||||
hasMore={!!query.hasNextPage}
|
||||
loading={query.isFetchingNextPage}
|
||||
failed={query.isFetchNextPageError}
|
||||
onMore={() => query.fetchNextPage()}
|
||||
endLabel="End of library"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import AlbumCard from '#lib/components/AlbumCard.svelte';
|
||||
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
|
||||
import EmptyState from '#lib/components/EmptyState.svelte';
|
||||
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
||||
import QuickFilter from '#lib/components/QuickFilter.svelte';
|
||||
import type { AlbumRef } from '#lib/api/types.js';
|
||||
|
||||
@@ -59,6 +60,9 @@
|
||||
let total = $state(0);
|
||||
let loading = $state(false);
|
||||
let failed = $state(false);
|
||||
// A later page failed: the albums already shown stay, and the list offers a
|
||||
// retry at its end instead of loading again on every scroll.
|
||||
let moreFailed = $state(false);
|
||||
|
||||
// Plain `let`, deliberately not $state: it's read inside the fetch path, and
|
||||
// as reactive state that read would make this effect depend on its own
|
||||
@@ -76,6 +80,7 @@
|
||||
albums = [];
|
||||
total = 0;
|
||||
failed = false;
|
||||
moreFailed = false;
|
||||
if (!genre) return;
|
||||
await fetchPage(genre, 0, requestToken);
|
||||
}
|
||||
@@ -88,13 +93,18 @@
|
||||
albums = offset === 0 ? p.items : [...albums, ...p.items];
|
||||
total = p.total;
|
||||
} catch {
|
||||
if (token === requestToken) failed = true;
|
||||
if (token === requestToken) {
|
||||
if (offset === 0) failed = true;
|
||||
else moreFailed = true;
|
||||
}
|
||||
} finally {
|
||||
if (token === requestToken) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (loading) return;
|
||||
moreFailed = false;
|
||||
void fetchPage(selected, albums.length, requestToken);
|
||||
}
|
||||
|
||||
@@ -154,21 +164,13 @@
|
||||
<AlbumCard {album} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if albums.length < total}
|
||||
<div class="flex justify-center py-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-4 py-2 text-sm hover:bg-surface-hover
|
||||
focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
||||
disabled={loading}
|
||||
onclick={loadMore}
|
||||
>
|
||||
{loading ? 'Loading…' : `Load more (${total - albums.length} left)`}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="py-2 text-center text-sm text-text-secondary">End of genre</p>
|
||||
{/if}
|
||||
<ListContinuation
|
||||
hasMore={albums.length < total}
|
||||
{loading}
|
||||
failed={moreFailed}
|
||||
onMore={loadMore}
|
||||
endLabel="End of genre"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/svelte';
|
||||
import { mockQuery } from '#test-utils/query.js';
|
||||
import { installIntersectionObserverMock } from '#test-utils/intersectionObserver.js';
|
||||
import { pageUrlModule } from '#test-utils/mocks/appState.js';
|
||||
import { apiClientMock } from '#test-utils/mocks/client.js';
|
||||
import { emptyLikesMock } from '#test-utils/mocks/likes.js';
|
||||
@@ -33,6 +34,11 @@ import { createGenresQuery, listAlbumsByGenre } from '#lib/api/browse.js';
|
||||
|
||||
const asMock = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
let io: ReturnType<typeof installIntersectionObserverMock>;
|
||||
beforeEach(() => {
|
||||
io = installIntersectionObserverMock();
|
||||
});
|
||||
|
||||
function album(id: string, title: string): AlbumRef {
|
||||
return {
|
||||
id,
|
||||
@@ -187,7 +193,7 @@ describe('/library/genres drill-down', () => {
|
||||
expect(screen.getByRole('heading', { name: 'Rock/Pop' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('load more appends the next page and then reports the end', async () => {
|
||||
test('scrolling to the end loads the next page, then reports the end', async () => {
|
||||
pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock');
|
||||
asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] }));
|
||||
asMock(listAlbumsByGenre)
|
||||
@@ -201,10 +207,13 @@ describe('/library/genres drill-down', () => {
|
||||
render(GenresPage);
|
||||
|
||||
await screen.findByText('One');
|
||||
const more = await screen.findByRole('button', { name: /Load more \(1 left\)/ });
|
||||
await fireEvent.click(more);
|
||||
// No button (#5416): reaching the bottom of the grid loads the next page.
|
||||
expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
io.scrollToEnd();
|
||||
expect(listAlbumsByGenre).toHaveBeenLastCalledWith('Rock', 2, 2);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(listAlbumsByGenre).toHaveBeenLastCalledWith('Rock', 2, 2));
|
||||
expect(await screen.findByText('Three')).toBeInTheDocument();
|
||||
// Earlier pages are appended, not replaced.
|
||||
expect(screen.getByText('One')).toBeInTheDocument();
|
||||
@@ -219,4 +228,27 @@ describe('/library/genres drill-down', () => {
|
||||
|
||||
expect(await screen.findByText(/Couldn't load albums for this genre/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// A failed later page keeps what is shown and offers a retry at the end,
|
||||
// rather than replacing the grid or re-requesting on every scroll.
|
||||
test('a failed later page keeps the albums and offers Try again', async () => {
|
||||
pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock');
|
||||
asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] }));
|
||||
asMock(listAlbumsByGenre)
|
||||
.mockResolvedValueOnce({ items: [album('a1', 'One'), album('a2', 'Two')], total: 3, limit: 2, offset: 0 })
|
||||
.mockRejectedValueOnce(new Error('nope'))
|
||||
.mockResolvedValueOnce({ items: [album('a3', 'Three')], total: 3, limit: 2, offset: 2 });
|
||||
render(GenresPage);
|
||||
|
||||
await screen.findByText('One');
|
||||
await waitFor(() => {
|
||||
io.scrollToEnd();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load more.");
|
||||
});
|
||||
expect(screen.getByText('One')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Couldn't load albums for this genre/i)).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
||||
expect(await screen.findByText('Three')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { groupByDay } from '#lib/utils/dayGroup.js';
|
||||
import HistoryRow from '#lib/components/HistoryRow.svelte';
|
||||
import TrackList from '#lib/components/TrackList.svelte';
|
||||
import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte';
|
||||
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
||||
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
|
||||
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
|
||||
import QuickFilter from '#lib/components/QuickFilter.svelte';
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
{#if query.isPending}
|
||||
<LibrarySkeleton variant="list" count={12} />
|
||||
{:else if query.isError}
|
||||
{:else if query.isError && !query.isFetchNextPageError}
|
||||
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
|
||||
{:else if flatEvents.length === 0}
|
||||
<EmptyState
|
||||
@@ -72,16 +72,12 @@
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
{#if query.hasNextPage}
|
||||
<InfiniteScrollSentinel
|
||||
enabled={!query.isFetchingNextPage}
|
||||
onIntersect={() => query.fetchNextPage()}
|
||||
/>
|
||||
{#if query.isFetchingNextPage}
|
||||
<p class="py-2 text-center text-sm text-text-secondary">Loading more…</p>
|
||||
{/if}
|
||||
{:else if flatEvents.length > 0}
|
||||
<p class="py-2 text-center text-sm text-text-secondary">End of history</p>
|
||||
{/if}
|
||||
<ListContinuation
|
||||
hasMore={!!query.hasNextPage}
|
||||
loading={query.isFetchingNextPage}
|
||||
failed={query.isFetchNextPageError}
|
||||
onMore={() => query.fetchNextPage()}
|
||||
endLabel="End of history"
|
||||
/>
|
||||
{/if}
|
||||
</PageColumn>
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
import TrackList from '#lib/components/TrackList.svelte';
|
||||
import QuickFilter from '#lib/components/QuickFilter.svelte';
|
||||
import EmptyState from '#lib/components/EmptyState.svelte';
|
||||
import TabStrip from '#lib/components/TabStrip.svelte';
|
||||
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
||||
|
||||
const artistsStore = $derived(createLikedArtistsInfiniteQuery());
|
||||
const albumsStore = $derived(createLikedAlbumsInfiniteQuery());
|
||||
@@ -29,6 +31,14 @@
|
||||
const albumsTotal = $derived(albumsQuery?.data?.pages?.[0]?.total ?? 0);
|
||||
const tracksTotal = $derived(tracksQuery?.data?.pages?.[0]?.total ?? 0);
|
||||
|
||||
type Tab = 'artists' | 'albums' | 'tracks';
|
||||
// Opens on the first tab with anything in it; once the reader picks a tab,
|
||||
// that choice sticks.
|
||||
let chosenTab = $state<Tab | null>(null);
|
||||
const tab = $derived<Tab>(
|
||||
chosenTab ?? (artistsTotal > 0 ? 'artists' : albumsTotal > 0 ? 'albums' : tracksTotal > 0 ? 'tracks' : 'artists')
|
||||
);
|
||||
|
||||
let filter = $state('');
|
||||
const q = $derived(filter.trim().toLowerCase());
|
||||
const fArtists = $derived(q ? artists.filter((a) => a.name.toLowerCase().includes(q)) : artists);
|
||||
@@ -41,8 +51,10 @@
|
||||
t.artist_name.toLowerCase().includes(q) ||
|
||||
(t.album_title ?? '').toLowerCase().includes(q))
|
||||
: tracks);
|
||||
// The filter applies to the open tab's loaded rows.
|
||||
const filterMatchesNothing = $derived(
|
||||
!!q && fArtists.length === 0 && fAlbums.length === 0 && fTracks.length === 0
|
||||
!!q &&
|
||||
(tab === 'artists' ? fArtists : tab === 'albums' ? fAlbums : fTracks).length === 0
|
||||
);
|
||||
const allEmpty = $derived(
|
||||
!q && artistsTotal === 0 && albumsTotal === 0 && tracksTotal === 0
|
||||
@@ -82,99 +94,79 @@
|
||||
</EmptyState>
|
||||
{/if}
|
||||
|
||||
{#if !allEmpty && (!q || fArtists.length > 0)}
|
||||
<section>
|
||||
<h2 class="mb-2 text-lg font-semibold">Artists</h2>
|
||||
{#if artistsTotal === 0}
|
||||
<EmptyState
|
||||
variant="inline"
|
||||
title="No liked artists yet."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<a href="/library/artists" class="text-accent-fg hover:underline">Browse artists →</a>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{#each fArtists as a (a.id)}
|
||||
<ArtistCard artist={a} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if artistsQuery?.hasNextPage}
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
|
||||
disabled={artistsQuery.isFetchingNextPage}
|
||||
onclick={() => artistsQuery.fetchNextPage()}
|
||||
>
|
||||
{artistsQuery.isFetchingNextPage ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{#if !allEmpty}
|
||||
<!-- One list at a time (#5416): stacked, a long Artists list that loads as
|
||||
you scroll would bury Albums and Tracks until every artist had loaded. -->
|
||||
<TabStrip
|
||||
bind:value={() => tab, (v) => (chosenTab = v)}
|
||||
ariaLabel="Liked"
|
||||
items={[
|
||||
{ id: 'artists', label: 'Artists', count: artistsTotal },
|
||||
{ id: 'albums', label: 'Albums', count: albumsTotal },
|
||||
{ id: 'tracks', label: 'Tracks', count: tracksTotal }
|
||||
]}
|
||||
/>
|
||||
|
||||
{#if !allEmpty && (!q || fAlbums.length > 0)}
|
||||
<section>
|
||||
<h2 class="mb-2 text-lg font-semibold">Albums</h2>
|
||||
{#if albumsTotal === 0}
|
||||
<EmptyState
|
||||
variant="inline"
|
||||
title="No liked albums yet."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<a href="/library/albums" class="text-accent-fg hover:underline">Browse albums →</a>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{#if tab === 'artists'}
|
||||
{#if artistsTotal === 0}
|
||||
<EmptyState variant="inline" title="No liked artists yet.">
|
||||
{#snippet actions()}
|
||||
<a href="/library/artists" class="text-accent-fg hover:underline">Browse artists →</a>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{#each fArtists as a (a.id)}
|
||||
<ArtistCard artist={a} />
|
||||
{/each}
|
||||
</div>
|
||||
<ListContinuation
|
||||
hasMore={!!artistsQuery?.hasNextPage}
|
||||
loading={!!artistsQuery?.isFetchingNextPage}
|
||||
failed={!!artistsQuery?.isFetchNextPageError}
|
||||
onMore={() => artistsQuery.fetchNextPage()}
|
||||
/>
|
||||
{/if}
|
||||
{:else if tab === 'albums'}
|
||||
{#if albumsTotal === 0}
|
||||
<EmptyState variant="inline" title="No liked albums yet.">
|
||||
{#snippet actions()}
|
||||
<a href="/library/albums" class="text-accent-fg hover:underline">Browse albums →</a>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{#each fAlbums as al (al.id)}
|
||||
<AlbumCard album={al} />
|
||||
{/each}
|
||||
</div>
|
||||
<ListContinuation
|
||||
hasMore={!!albumsQuery?.hasNextPage}
|
||||
loading={!!albumsQuery?.isFetchingNextPage}
|
||||
failed={!!albumsQuery?.isFetchNextPageError}
|
||||
onMore={() => albumsQuery.fetchNextPage()}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{#each fAlbums as al (al.id)}
|
||||
<AlbumCard album={al} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if albumsQuery?.hasNextPage}
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
|
||||
disabled={albumsQuery.isFetchingNextPage}
|
||||
onclick={() => albumsQuery.fetchNextPage()}
|
||||
>
|
||||
{albumsQuery.isFetchingNextPage ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
{#if tracksTotal === 0}
|
||||
<EmptyState variant="inline" title="No liked tracks yet.">
|
||||
{#snippet actions()}
|
||||
<a href="/" class="text-accent-fg hover:underline">Find something new →</a>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<TrackList>
|
||||
{#each fTracks as t, i (t.id)}
|
||||
<TrackRow tracks={fTracks} index={i} />
|
||||
{/each}
|
||||
</TrackList>
|
||||
<ListContinuation
|
||||
hasMore={!!tracksQuery?.hasNextPage}
|
||||
loading={!!tracksQuery?.isFetchingNextPage}
|
||||
failed={!!tracksQuery?.isFetchNextPageError}
|
||||
onMore={() => tracksQuery.fetchNextPage()}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if !allEmpty && (!q || fTracks.length > 0)}
|
||||
<section>
|
||||
<h2 class="mb-2 text-lg font-semibold">Tracks</h2>
|
||||
{#if tracksTotal === 0}
|
||||
<EmptyState
|
||||
variant="inline"
|
||||
title="No liked tracks yet."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<a href="/" class="text-accent-fg hover:underline">Find something new →</a>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<TrackList>
|
||||
{#each fTracks as t, i (t.id)}
|
||||
<TrackRow tracks={fTracks} index={i} />
|
||||
{/each}
|
||||
</TrackList>
|
||||
{#if tracksQuery?.hasNextPage}
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
|
||||
disabled={tracksQuery.isFetchingNextPage}
|
||||
onclick={() => tracksQuery.fetchNextPage()}
|
||||
>
|
||||
{tracksQuery.isFetchingNextPage ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</PageColumn>
|
||||
|
||||
@@ -38,82 +38,99 @@ function page<T>(items: T[], total: number, offset = 0, limit = 50): Page<T> {
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
const ar: ArtistRef = { id: 'a1', name: 'Miles', sort_name: 'Miles', album_count: 1, cover_url: '' };
|
||||
const al: AlbumRef = {
|
||||
id: 'al1', title: 'Kind of Blue', sort_title: 'Kind of Blue', artist_id: 'a1', artist_name: 'Miles',
|
||||
year: 1959, track_count: 1, duration_sec: 100, cover_url: '/x', cover_art_source: null
|
||||
};
|
||||
const tr: TrackRef = makeTrack({
|
||||
title: 'So What', album_id: 'al1', album_title: 'Kind of Blue',
|
||||
artist_id: 'a1', artist_name: 'Miles', duration_sec: 100
|
||||
});
|
||||
|
||||
function setup(opts: {
|
||||
artists?: Page<ArtistRef>;
|
||||
albums?: Page<AlbumRef>;
|
||||
tracks?: Page<TrackRef>;
|
||||
tracksQuery?: Parameters<typeof mockInfiniteQuery>[0];
|
||||
}) {
|
||||
(createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [opts.artists ?? page<ArtistRef>([], 0)] })
|
||||
);
|
||||
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [opts.albums ?? page<AlbumRef>([], 0)] })
|
||||
);
|
||||
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [opts.tracks ?? page<TrackRef>([], 0)], ...opts.tracksQuery })
|
||||
);
|
||||
}
|
||||
|
||||
describe('liked library page', () => {
|
||||
test('renders three sections when each has items', () => {
|
||||
const ar: ArtistRef = { id: 'a1', name: 'X', sort_name: 'X', album_count: 1, cover_url: '' };
|
||||
const al: AlbumRef = {
|
||||
id: 'al1', title: 'Y', sort_title: 'Y', artist_id: 'a1', artist_name: 'X',
|
||||
year: 2020, track_count: 1, duration_sec: 100, cover_url: '/x', cover_art_source: null
|
||||
};
|
||||
const tr: TrackRef = makeTrack({
|
||||
title: 'Z', album_id: 'al1', album_title: 'Y',
|
||||
artist_id: 'a1', artist_name: 'X', duration_sec: 100
|
||||
});
|
||||
(createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page([ar], 1)] })
|
||||
);
|
||||
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page([al], 1)] })
|
||||
);
|
||||
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page([tr], 1)] })
|
||||
);
|
||||
// #5416: one list at a time. Stacked, a long Artists list that loads as you
|
||||
// scroll would bury Albums and Tracks.
|
||||
test('tabs carry each count and show one list at a time', async () => {
|
||||
setup({ artists: page([ar], 1), albums: page([al], 1), tracks: page([tr], 1) });
|
||||
render(LikedPage);
|
||||
expect(screen.getByRole('heading', { name: /artists/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /albums/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /tracks/i })).toBeInTheDocument();
|
||||
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
expect(tabs.map((t) => t.textContent?.replace(/\s+/g, ' ').trim())).toEqual([
|
||||
'Artists 1',
|
||||
'Albums 1',
|
||||
'Tracks 1'
|
||||
]);
|
||||
expect(screen.getByText('Miles')).toBeInTheDocument();
|
||||
expect(screen.queryByText('So What')).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(screen.getByRole('tab', { name: /albums/i }));
|
||||
expect(screen.getByText('Kind of Blue')).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(screen.getByRole('tab', { name: /tracks/i }));
|
||||
expect(screen.getByText('So What')).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /tracks/i })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('all three sections empty renders the whole-tab onboarding card', () => {
|
||||
(createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<ArtistRef>([], 0)] })
|
||||
);
|
||||
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<AlbumRef>([], 0)] })
|
||||
);
|
||||
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<TrackRef>([], 0)] })
|
||||
);
|
||||
test('opens on the first tab that has likes', () => {
|
||||
setup({ tracks: page([tr], 1) });
|
||||
render(LikedPage);
|
||||
expect(screen.getByRole('tab', { name: /tracks/i })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByText('So What')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('all three empty renders the whole-page onboarding card and no tabs', () => {
|
||||
setup({});
|
||||
render(LikedPage);
|
||||
// The whole-tab EmptyState replaces the three per-section hints
|
||||
// when every section is empty — single heading + action affordances.
|
||||
expect(screen.getByRole('heading', { name: /no likes yet/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /explore home/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /browse albums/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('one populated section + two empty subsections renders inline hints', () => {
|
||||
(createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<ArtistRef>([{ id: 'a1', name: 'Miles' } as ArtistRef], 1)] })
|
||||
);
|
||||
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<AlbumRef>([], 0)] })
|
||||
);
|
||||
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<TrackRef>([], 0)] })
|
||||
);
|
||||
test('an empty tab shows its inline hint', async () => {
|
||||
setup({ artists: page([ar], 1) });
|
||||
render(LikedPage);
|
||||
await fireEvent.click(screen.getByRole('tab', { name: /albums/i }));
|
||||
expect(screen.getByText(/no liked albums yet/i)).toBeInTheDocument();
|
||||
await fireEvent.click(screen.getByRole('tab', { name: /tracks/i }));
|
||||
expect(screen.getByText(/no liked tracks yet/i)).toBeInTheDocument();
|
||||
// Inline variant uses link affordances, not the big card heading.
|
||||
expect(screen.queryByRole('heading', { name: /no likes yet/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Load more calls fetchNextPage on each section that has more', async () => {
|
||||
const fetchTracks = vi.fn();
|
||||
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<TrackRef>([], 100)], hasNextPage: true, fetchNextPage: fetchTracks })
|
||||
);
|
||||
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<AlbumRef>([], 0)] })
|
||||
);
|
||||
(createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<ArtistRef>([], 0)] })
|
||||
);
|
||||
// Preference 172: the list continues itself; there is no button to press.
|
||||
test('more tracks load as you scroll, with no Load more button', () => {
|
||||
setup({ tracks: page([tr], 100), tracksQuery: { hasNextPage: true } });
|
||||
const { container } = render(LikedPage);
|
||||
expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument();
|
||||
expect(container.querySelector('div[aria-hidden="true"].h-px')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a failed page offers Try again, which fetches it', async () => {
|
||||
const fetchNextPage = vi.fn();
|
||||
setup({
|
||||
tracks: page([tr], 100),
|
||||
tracksQuery: { hasNextPage: true, isFetchNextPageError: true, fetchNextPage }
|
||||
});
|
||||
render(LikedPage);
|
||||
const buttons = screen.getAllByRole('button', { name: /load more/i });
|
||||
expect(buttons).toHaveLength(1);
|
||||
await fireEvent.click(buttons[0]);
|
||||
expect(fetchTracks).toHaveBeenCalledTimes(1);
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import AlbumCard from '#lib/components/AlbumCard.svelte';
|
||||
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
|
||||
import EmptyState from '#lib/components/EmptyState.svelte';
|
||||
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
||||
import type { AlbumRef } from '#lib/api/types.js';
|
||||
|
||||
const indexStore = createAlbumYearsQuery();
|
||||
@@ -48,6 +49,9 @@
|
||||
let total = $state(0);
|
||||
let loading = $state(false);
|
||||
let failed = $state(false);
|
||||
// A later page failed: the albums already shown stay, and the list offers a
|
||||
// retry at its end instead of loading again on every scroll.
|
||||
let moreFailed = $state(false);
|
||||
|
||||
// Plain `let`, not $state — see the note in the genres page: as reactive
|
||||
// state, reading it in the fetch path would make the effect below depend on
|
||||
@@ -65,6 +69,7 @@
|
||||
albums = [];
|
||||
total = 0;
|
||||
failed = false;
|
||||
moreFailed = false;
|
||||
if (year === null) return;
|
||||
await fetchPage(year, 0, requestToken);
|
||||
}
|
||||
@@ -77,14 +82,19 @@
|
||||
albums = offset === 0 ? p.items : [...albums, ...p.items];
|
||||
total = p.total;
|
||||
} catch {
|
||||
if (token === requestToken) failed = true;
|
||||
if (token === requestToken) {
|
||||
if (offset === 0) failed = true;
|
||||
else moreFailed = true;
|
||||
}
|
||||
} finally {
|
||||
if (token === requestToken) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (selected !== null) void fetchPage(selected, albums.length, requestToken);
|
||||
if (selected === null || loading) return;
|
||||
moreFailed = false;
|
||||
void fetchPage(selected, albums.length, requestToken);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -136,21 +146,13 @@
|
||||
<AlbumCard {album} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if albums.length < total}
|
||||
<div class="flex justify-center py-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-4 py-2 text-sm hover:bg-surface-hover
|
||||
focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
||||
disabled={loading}
|
||||
onclick={loadMore}
|
||||
>
|
||||
{loading ? 'Loading…' : `Load more (${total - albums.length} left)`}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="py-2 text-center text-sm text-text-secondary">End of year</p>
|
||||
{/if}
|
||||
<ListContinuation
|
||||
hasMore={albums.length < total}
|
||||
{loading}
|
||||
failed={moreFailed}
|
||||
onMore={loadMore}
|
||||
endLabel="End of year"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/svelte';
|
||||
import { mockQuery } from '#test-utils/query.js';
|
||||
import { installIntersectionObserverMock } from '#test-utils/intersectionObserver.js';
|
||||
import { pageUrlModule } from '#test-utils/mocks/appState.js';
|
||||
import { apiClientMock } from '#test-utils/mocks/client.js';
|
||||
import { emptyLikesMock } from '#test-utils/mocks/likes.js';
|
||||
@@ -33,6 +34,11 @@ import { createAlbumYearsQuery, listAlbumsByYear } from '#lib/api/browse.js';
|
||||
|
||||
const asMock = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
let io: ReturnType<typeof installIntersectionObserverMock>;
|
||||
beforeEach(() => {
|
||||
io = installIntersectionObserverMock();
|
||||
});
|
||||
|
||||
function album(id: string, title: string): AlbumRef {
|
||||
return {
|
||||
id,
|
||||
@@ -146,7 +152,7 @@ describe('/library/years drill-down', () => {
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Years' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('load more appends and then reports the end', async () => {
|
||||
test('scrolling to the end loads the next page, then reports the end', async () => {
|
||||
pageState.pageUrl = new URL('http://localhost/library/years?y=1995');
|
||||
asMock(createAlbumYearsQuery).mockReturnValue(mockQuery({ data: [] }));
|
||||
asMock(listAlbumsByYear)
|
||||
@@ -160,9 +166,13 @@ describe('/library/years drill-down', () => {
|
||||
render(YearsPage);
|
||||
|
||||
await screen.findByText('One');
|
||||
await fireEvent.click(await screen.findByRole('button', { name: /Load more \(1 left\)/ }));
|
||||
// No button (#5416): reaching the bottom of the grid loads the next page.
|
||||
expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
io.scrollToEnd();
|
||||
expect(listAlbumsByYear).toHaveBeenLastCalledWith(1995, 2, 2);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(listAlbumsByYear).toHaveBeenLastCalledWith(1995, 2, 2));
|
||||
expect(await screen.findByText('Three')).toBeInTheDocument();
|
||||
expect(screen.getByText('One')).toBeInTheDocument();
|
||||
expect(await screen.findByText('End of year')).toBeInTheDocument();
|
||||
@@ -176,4 +186,27 @@ describe('/library/years drill-down', () => {
|
||||
|
||||
expect(await screen.findByText(/Couldn't load albums for 1995/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// A failed later page keeps what is shown and offers a retry at the end,
|
||||
// rather than replacing the grid or re-requesting on every scroll.
|
||||
test('a failed later page keeps the albums and offers Try again', async () => {
|
||||
pageState.pageUrl = new URL('http://localhost/library/years?y=1995');
|
||||
asMock(createAlbumYearsQuery).mockReturnValue(mockQuery({ data: [] }));
|
||||
asMock(listAlbumsByYear)
|
||||
.mockResolvedValueOnce({ items: [album('a1', 'One'), album('a2', 'Two')], total: 3, limit: 2, offset: 0 })
|
||||
.mockRejectedValueOnce(new Error('nope'))
|
||||
.mockResolvedValueOnce({ items: [album('a3', 'Three')], total: 3, limit: 2, offset: 2 });
|
||||
render(YearsPage);
|
||||
|
||||
await screen.findByText('One');
|
||||
await waitFor(() => {
|
||||
io.scrollToEnd();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load more.");
|
||||
});
|
||||
expect(screen.getByText('One')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Couldn't load albums for 1995/i)).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
||||
expect(await screen.findByText('Three')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import AlbumCard from '#lib/components/AlbumCard.svelte';
|
||||
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
|
||||
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
|
||||
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
||||
import { useDelayed } from '#lib/utils/useDelayed.svelte.js';
|
||||
|
||||
const q = $derived((page.url.searchParams.get('q') ?? '').trim());
|
||||
@@ -24,14 +25,14 @@
|
||||
← Back to search
|
||||
</a>
|
||||
<h1 class="mt-2 text-2xl font-semibold">Albums matching '{q}'</h1>
|
||||
{#if !query?.isPending && !query?.isError && q}
|
||||
{#if q && !query?.isPending && !(query?.isError && albums.length === 0)}
|
||||
<p class="text-sm text-text-secondary">{total} {total === 1 ? 'album' : 'albums'}</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if !q}
|
||||
<p class="text-text-secondary">No query — return to search to start typing.</p>
|
||||
{:else if query?.isError}
|
||||
{:else if query?.isError && albums.length === 0}
|
||||
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
|
||||
{:else if showSkeleton.value && albums.length === 0}
|
||||
<LibrarySkeleton variant="grid" count={12} />
|
||||
@@ -43,15 +44,11 @@
|
||||
<AlbumCard album={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>
|
||||
{/if}
|
||||
<ListContinuation
|
||||
hasMore={!!query?.hasNextPage}
|
||||
loading={!!query?.isFetchingNextPage}
|
||||
failed={!!query?.isFetchNextPageError}
|
||||
onMore={() => query?.fetchNextPage()}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import { render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { mockInfiniteQuery } from '../../../test-utils/query';
|
||||
import { installIntersectionObserverMock } from '#test-utils/intersectionObserver.js';
|
||||
import { emptyLikesMock } from '../../../test-utils/mocks/likes';
|
||||
import { apiClientMock } from '../../../test-utils/mocks/client';
|
||||
import { pageUrlModule } from '#test-utils/mocks/appState.js';
|
||||
@@ -47,7 +48,9 @@ describe('search albums overflow', () => {
|
||||
expect(screen.getByRole('link', { name: /Kind of Blue/ })).toHaveAttribute('href', '/albums/al1');
|
||||
});
|
||||
|
||||
test('Load more calls fetchNextPage', async () => {
|
||||
// #5416: no button; reaching the end of the list loads the next page.
|
||||
test('scrolling to the end loads the next page', async () => {
|
||||
const io = installIntersectionObserverMock();
|
||||
const fetchNextPage = vi.fn();
|
||||
(createSearchAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({
|
||||
@@ -57,8 +60,11 @@ describe('search albums overflow', () => {
|
||||
})
|
||||
);
|
||||
render(AlbumsOverflow);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /load more/i }));
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
io.scrollToEnd();
|
||||
expect(fetchNextPage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('empty q shows a prompt', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import ArtistCard from '#lib/components/ArtistCard.svelte';
|
||||
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
|
||||
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
|
||||
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
||||
import { useDelayed } from '#lib/utils/useDelayed.svelte.js';
|
||||
|
||||
const q = $derived((page.url.searchParams.get('q') ?? '').trim());
|
||||
@@ -24,14 +25,14 @@
|
||||
← Back to search
|
||||
</a>
|
||||
<h1 class="mt-2 text-2xl font-semibold">Artists matching '{q}'</h1>
|
||||
{#if !query?.isPending && !query?.isError && q}
|
||||
{#if q && !query?.isPending && !(query?.isError && artists.length === 0)}
|
||||
<p class="text-sm text-text-secondary">{total} {total === 1 ? 'artist' : 'artists'}</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if !q}
|
||||
<p class="text-text-secondary">No query — return to search to start typing.</p>
|
||||
{:else if query?.isError}
|
||||
{:else if query?.isError && artists.length === 0}
|
||||
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
|
||||
{:else if showSkeleton.value && artists.length === 0}
|
||||
<LibrarySkeleton variant="list" count={12} />
|
||||
@@ -43,15 +44,11 @@
|
||||
<ArtistCard 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>
|
||||
{/if}
|
||||
<ListContinuation
|
||||
hasMore={!!query?.hasNextPage}
|
||||
loading={!!query?.isFetchingNextPage}
|
||||
failed={!!query?.isFetchNextPageError}
|
||||
onMore={() => query?.fetchNextPage()}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import { render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { mockInfiniteQuery } from '../../../test-utils/query';
|
||||
import { installIntersectionObserverMock } from '#test-utils/intersectionObserver.js';
|
||||
import { emptyLikesMock } from '../../../test-utils/mocks/likes';
|
||||
import { pageUrlModule } from '#test-utils/mocks/appState.js';
|
||||
import type { ArtistRef, Page } from '#lib/api/types.js';
|
||||
@@ -39,7 +40,9 @@ describe('search artists overflow', () => {
|
||||
expect(screen.getByRole('link', { name: /Miles Mosley/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Load more calls fetchNextPage when hasNextPage', async () => {
|
||||
// #5416: no button; reaching the end of the list loads the next page.
|
||||
test('scrolling to the end loads the next page', async () => {
|
||||
const io = installIntersectionObserverMock();
|
||||
const fetchNextPage = vi.fn();
|
||||
(createSearchArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({
|
||||
@@ -49,16 +52,19 @@ describe('search artists overflow', () => {
|
||||
})
|
||||
);
|
||||
render(ArtistsOverflow);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /load more/i }));
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
io.scrollToEnd();
|
||||
expect(fetchNextPage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('Load more is hidden when hasNextPage is false', () => {
|
||||
test('nothing more to load: no sentinel', () => {
|
||||
(createSearchArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<ArtistRef>([{ id: 'a', name: 'A', sort_name: 'A', album_count: 1, cover_url: '' }], 1)] })
|
||||
);
|
||||
render(ArtistsOverflow);
|
||||
expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument();
|
||||
const { container } = render(ArtistsOverflow);
|
||||
expect(container.querySelector('div[aria-hidden="true"].h-px')).toBeNull();
|
||||
});
|
||||
|
||||
test('empty q shows a prompt and fires no query', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import TrackList from '#lib/components/TrackList.svelte';
|
||||
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
|
||||
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
|
||||
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
||||
import { useDelayed } from '#lib/utils/useDelayed.svelte.js';
|
||||
import { playRadio } from '#lib/player/store.svelte.js';
|
||||
import type { TrackRef } from '#lib/api/types.js';
|
||||
@@ -32,14 +33,14 @@
|
||||
← Back to search
|
||||
</a>
|
||||
<h1 class="mt-2 font-display text-2xl font-medium text-text-primary">Tracks matching '{q}'</h1>
|
||||
{#if !query?.isPending && !query?.isError && q}
|
||||
{#if q && !query?.isPending && !(query?.isError && tracks.length === 0)}
|
||||
<p class="text-sm text-text-secondary">{total} {total === 1 ? 'track' : 'tracks'}</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if !q}
|
||||
<p class="text-text-secondary">No query — return to search to start typing.</p>
|
||||
{:else if query?.isError}
|
||||
{:else if query?.isError && tracks.length === 0}
|
||||
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
|
||||
{:else if showSkeleton.value && tracks.length === 0}
|
||||
<LibrarySkeleton variant="list" count={12} />
|
||||
@@ -51,15 +52,11 @@
|
||||
<TrackRow tracks={tracks} index={i} onPlay={onTrackPlay} />
|
||||
{/each}
|
||||
</TrackList>
|
||||
{#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>
|
||||
{/if}
|
||||
<ListContinuation
|
||||
hasMore={!!query?.hasNextPage}
|
||||
loading={!!query?.isFetchingNextPage}
|
||||
failed={!!query?.isFetchNextPageError}
|
||||
onMore={() => query?.fetchNextPage()}
|
||||
/>
|
||||
{/if}
|
||||
</PageColumn>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { mockInfiniteQuery } from '../../../test-utils/query';
|
||||
import { installIntersectionObserverMock } from '#test-utils/intersectionObserver.js';
|
||||
import { emptyLikesMock } from '../../../test-utils/mocks/likes';
|
||||
import { emptyQuarantineMock } from '../../../test-utils/mocks/quarantine';
|
||||
import { pageUrlModule } from '#test-utils/mocks/appState.js';
|
||||
@@ -59,7 +60,9 @@ describe('search tracks overflow', () => {
|
||||
expect(playRadio).toHaveBeenCalledWith('t1');
|
||||
});
|
||||
|
||||
test('Load more calls fetchNextPage', async () => {
|
||||
// #5416: no button; reaching the end of the list loads the next page.
|
||||
test('scrolling to the end loads the next page', async () => {
|
||||
const io = installIntersectionObserverMock();
|
||||
const fetchNextPage = vi.fn();
|
||||
(createSearchTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({
|
||||
@@ -69,8 +72,11 @@ describe('search tracks overflow', () => {
|
||||
})
|
||||
);
|
||||
render(TracksOverflow);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /load more/i }));
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
io.scrollToEnd();
|
||||
expect(fetchNextPage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('empty q shows a prompt', () => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// jsdom doesn't ship IntersectionObserver, so a list that loads as you scroll
|
||||
// (InfiniteScrollSentinel, ListContinuation) never fires in a test. This
|
||||
// installs a stand-in that records each observer, so a test can play
|
||||
// "the reader scrolled to the bottom" with scrollToEnd().
|
||||
type Observer = { cb: IntersectionObserverCallback; self: IntersectionObserver; connected: boolean };
|
||||
|
||||
export function installIntersectionObserverMock() {
|
||||
const observers: Observer[] = [];
|
||||
|
||||
class MockIntersectionObserver {
|
||||
observe = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
disconnect = vi.fn(() => {
|
||||
const o = observers.find((x) => x.self === (this as unknown as IntersectionObserver));
|
||||
if (o) o.connected = false;
|
||||
});
|
||||
takeRecords = vi.fn(() => []);
|
||||
constructor(cb: IntersectionObserverCallback) {
|
||||
observers.push({ cb, self: this as unknown as IntersectionObserver, connected: true });
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||
|
||||
return {
|
||||
/** Reports every connected observer's target as on screen. */
|
||||
scrollToEnd() {
|
||||
for (const o of observers) {
|
||||
if (!o.connected) continue;
|
||||
o.cb([{ isIntersecting: true } as IntersectionObserverEntry], o.self);
|
||||
}
|
||||
},
|
||||
/** How many observers are currently watching. */
|
||||
connectedCount() {
|
||||
return observers.filter((o) => o.connected).length;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export function mockInfiniteQuery<T>(opts: {
|
||||
error?: unknown;
|
||||
hasNextPage?: boolean;
|
||||
isFetchingNextPage?: boolean;
|
||||
isFetchNextPageError?: boolean;
|
||||
fetchNextPage?: () => void;
|
||||
refetch?: () => void;
|
||||
} = {}) {
|
||||
@@ -24,6 +25,7 @@ export function mockInfiniteQuery<T>(opts: {
|
||||
error: opts.error,
|
||||
hasNextPage: opts.hasNextPage ?? false,
|
||||
isFetchingNextPage: opts.isFetchingNextPage ?? false,
|
||||
isFetchNextPageError: opts.isFetchNextPageError ?? false,
|
||||
fetchNextPage: opts.fetchNextPage ?? (() => {}),
|
||||
refetch: opts.refetch ?? (() => {})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user