feat(documents): paginate search with a Pagination control
Frontend side of the /documents pagination work. The page.server.ts load reads ?page= from the URL, forwards page+size=50 to the backend, and exposes the new totalElements/pageNumber/pageSize/totalPages fields on `data`. +page.svelte renders a <Pagination> component below the result list; buildPageHref preserves every filter param and only updates page. The existing triggerSearch debounce flow intentionally drops `page` when any filter changes, so filter edits reset to page 0 automatically. <Pagination> uses plain <a href> links (not goto) so SvelteKit's default scroll restoration scrolls new pages to the top — the expected senior-UX behaviour. Decorative chevrons wrapped in aria-hidden spans, 44px touch targets, focus-visible ring, stacks vertically under 640px. The control hides itself when totalPages ≤ 1. Test coverage: 9 cases on Pagination (label, aria-current, prev/next enable/disable, makeHref invocation, decorative chevron, touch target), plus a filter-reset assertion on +page.svelte (page 5 → edit q → goto URL must drop page=). Adds i18n keys in de/en/es. Manual edit to api.ts pending a post-merge npm run generate:api against a rebuilt dev backend. (#315) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -806,5 +806,9 @@
|
|||||||
"chronik_load_more": "Mehr laden",
|
"chronik_load_more": "Mehr laden",
|
||||||
"chronik_loading": "Lädt …",
|
"chronik_loading": "Lädt …",
|
||||||
"chronik_load_more_announcement": "{count} weitere Einträge geladen",
|
"chronik_load_more_announcement": "{count} weitere Einträge geladen",
|
||||||
"chronik_view_all": "Alle Aktivitäten →"
|
"chronik_view_all": "Alle Aktivitäten →",
|
||||||
|
"pagination_prev": "Zurück",
|
||||||
|
"pagination_next": "Weiter",
|
||||||
|
"pagination_page_of": "Seite {page} von {total}",
|
||||||
|
"pagination_nav_label": "Seitennavigation"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -806,5 +806,9 @@
|
|||||||
"chronik_load_more": "Load more",
|
"chronik_load_more": "Load more",
|
||||||
"chronik_loading": "Loading …",
|
"chronik_loading": "Loading …",
|
||||||
"chronik_load_more_announcement": "{count} more entries loaded",
|
"chronik_load_more_announcement": "{count} more entries loaded",
|
||||||
"chronik_view_all": "All activity →"
|
"chronik_view_all": "All activity →",
|
||||||
|
"pagination_prev": "Previous",
|
||||||
|
"pagination_next": "Next",
|
||||||
|
"pagination_page_of": "Page {page} of {total}",
|
||||||
|
"pagination_nav_label": "Pagination"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -806,5 +806,9 @@
|
|||||||
"chronik_load_more": "Cargar más",
|
"chronik_load_more": "Cargar más",
|
||||||
"chronik_loading": "Cargando …",
|
"chronik_loading": "Cargando …",
|
||||||
"chronik_load_more_announcement": "{count} entradas más cargadas",
|
"chronik_load_more_announcement": "{count} entradas más cargadas",
|
||||||
"chronik_view_all": "Todas las actividades →"
|
"chronik_view_all": "Todas las actividades →",
|
||||||
|
"pagination_prev": "Anterior",
|
||||||
|
"pagination_next": "Siguiente",
|
||||||
|
"pagination_page_of": "Página {page} de {total}",
|
||||||
|
"pagination_nav_label": "Paginación"
|
||||||
}
|
}
|
||||||
|
|||||||
58
frontend/src/lib/components/Pagination.svelte
Normal file
58
frontend/src/lib/components/Pagination.svelte
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 0-indexed current page. */
|
||||||
|
page: number;
|
||||||
|
/** Total number of pages. `0` or `1` hides the control as trivially there's nothing to navigate. */
|
||||||
|
totalPages: number;
|
||||||
|
/** Given a 0-indexed page number, returns the href the link should point at. */
|
||||||
|
makeHref: (page: number) => string;
|
||||||
|
/** Optional override for the outer `<nav>`'s aria-label. */
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page, totalPages, makeHref, ariaLabel }: Props = $props();
|
||||||
|
|
||||||
|
const hasPrev = $derived(page > 0);
|
||||||
|
const hasNext = $derived(page < totalPages - 1);
|
||||||
|
const linkBase =
|
||||||
|
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1.5 rounded-sm border border-line bg-white px-4 py-2 font-sans text-sm font-bold text-ink transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-40';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<nav
|
||||||
|
aria-label={ariaLabel ?? m.pagination_nav_label()}
|
||||||
|
class="mt-6 flex flex-col items-center gap-3 sm:flex-row sm:justify-between"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
data-testid="pagination-prev"
|
||||||
|
aria-label={m.pagination_prev()}
|
||||||
|
aria-disabled={!hasPrev}
|
||||||
|
href={hasPrev ? makeHref(page - 1) : undefined}
|
||||||
|
class={linkBase}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">«</span>
|
||||||
|
{m.pagination_prev()}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<span
|
||||||
|
data-testid="pagination-page-label"
|
||||||
|
aria-current="page"
|
||||||
|
class="font-sans text-sm text-ink-2"
|
||||||
|
>
|
||||||
|
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<a
|
||||||
|
data-testid="pagination-next"
|
||||||
|
aria-label={m.pagination_next()}
|
||||||
|
aria-disabled={!hasNext}
|
||||||
|
href={hasNext ? makeHref(page + 1) : undefined}
|
||||||
|
class={linkBase}
|
||||||
|
>
|
||||||
|
{m.pagination_next()}
|
||||||
|
<span aria-hidden="true">»</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
84
frontend/src/lib/components/Pagination.svelte.spec.ts
Normal file
84
frontend/src/lib/components/Pagination.svelte.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
import Pagination from './Pagination.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeHref = (p: number) => `/documents?page=${p}`;
|
||||||
|
|
||||||
|
describe('Pagination', () => {
|
||||||
|
it('renders the page-of-total label for the current page', async () => {
|
||||||
|
render(Pagination, { page: 2, totalPages: 10, makeHref });
|
||||||
|
|
||||||
|
const label = page.getByTestId('pagination-page-label');
|
||||||
|
await expect.element(label).toHaveTextContent(/3/); // page is 0-indexed, label is 1-indexed
|
||||||
|
await expect.element(label).toHaveTextContent(/10/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the current page label with aria-current="page"', async () => {
|
||||||
|
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||||
|
|
||||||
|
const label = page.getByTestId('pagination-page-label');
|
||||||
|
await expect.element(label).toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders prev as a link pointing at page - 1 when not on first page', async () => {
|
||||||
|
render(Pagination, { page: 4, totalPages: 10, makeHref });
|
||||||
|
|
||||||
|
const prev = page.getByTestId('pagination-prev');
|
||||||
|
await expect.element(prev).toHaveAttribute('href', '/documents?page=3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables prev on page 0 (no href, aria-disabled="true")', async () => {
|
||||||
|
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||||
|
|
||||||
|
const prev = page.getByTestId('pagination-prev');
|
||||||
|
await expect.element(prev).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
await expect.element(prev).not.toHaveAttribute('href');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders next as a link pointing at page + 1 when not on last page', async () => {
|
||||||
|
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||||
|
|
||||||
|
const next = page.getByTestId('pagination-next');
|
||||||
|
await expect.element(next).toHaveAttribute('href', '/documents?page=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables next on the last page (no href, aria-disabled="true")', async () => {
|
||||||
|
render(Pagination, { page: 2, totalPages: 3, makeHref });
|
||||||
|
|
||||||
|
const next = page.getByTestId('pagination-next');
|
||||||
|
await expect.element(next).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
await expect.element(next).not.toHaveAttribute('href');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls makeHref with p-1 and p+1', async () => {
|
||||||
|
const spy = vi.fn(makeHref);
|
||||||
|
render(Pagination, { page: 3, totalPages: 10, makeHref: spy });
|
||||||
|
|
||||||
|
const calls = spy.mock.calls.map((c) => c[0]).sort((a, b) => a - b);
|
||||||
|
expect(calls).toContain(2);
|
||||||
|
expect(calls).toContain(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders decorative chevron inside aria-hidden span so screen readers skip it', async () => {
|
||||||
|
render(Pagination, { page: 1, totalPages: 3, makeHref });
|
||||||
|
|
||||||
|
const prev = page.getByTestId('pagination-prev');
|
||||||
|
await expect
|
||||||
|
.element(prev.getByText('«', { exact: true }))
|
||||||
|
.toHaveAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prev and next have min 44px touch targets', async () => {
|
||||||
|
render(Pagination, { page: 1, totalPages: 3, makeHref });
|
||||||
|
|
||||||
|
const prev = page.getByTestId('pagination-prev');
|
||||||
|
await expect.element(prev).toHaveClass(/min-h-\[44px\]/);
|
||||||
|
await expect.element(prev).toHaveClass(/min-w-\[44px\]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1921,7 +1921,13 @@ export interface components {
|
|||||||
DocumentSearchResult: {
|
DocumentSearchResult: {
|
||||||
items: components["schemas"]["DocumentSearchItem"][];
|
items: components["schemas"]["DocumentSearchItem"][];
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
total: number;
|
totalElements: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
pageNumber: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
pageSize: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
totalPages: number;
|
||||||
};
|
};
|
||||||
MatchOffset: {
|
MatchOffset: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
@@ -4032,6 +4038,16 @@ export interface operations {
|
|||||||
dir?: string;
|
dir?: string;
|
||||||
/** @description Tag operator: AND (default) or OR */
|
/** @description Tag operator: AND (default) or OR */
|
||||||
tagOp?: string;
|
tagOp?: string;
|
||||||
|
/**
|
||||||
|
* @description Page number (0-indexed)
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
page?: number;
|
||||||
|
/**
|
||||||
|
* @description Page size (max 100)
|
||||||
|
* @default 50
|
||||||
|
*/
|
||||||
|
size?: number;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ type ValidSort = (typeof VALID_SORTS)[number];
|
|||||||
const VALID_DIRS = ['asc', 'desc'] as const;
|
const VALID_DIRS = ['asc', 'desc'] as const;
|
||||||
type ValidDir = (typeof VALID_DIRS)[number];
|
type ValidDir = (typeof VALID_DIRS)[number];
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
export async function load({ url, fetch }) {
|
export async function load({ url, fetch }) {
|
||||||
const q = url.searchParams.get('q') || '';
|
const q = url.searchParams.get('q') || '';
|
||||||
const from = url.searchParams.get('from') || '';
|
const from = url.searchParams.get('from') || '';
|
||||||
@@ -27,6 +29,7 @@ export async function load({ url, fetch }) {
|
|||||||
: 'desc';
|
: 'desc';
|
||||||
const tagQ = url.searchParams.get('tagQ') || '';
|
const tagQ = url.searchParams.get('tagQ') || '';
|
||||||
const tagOp = url.searchParams.get('tagOp') === 'OR' ? 'OR' : 'AND';
|
const tagOp = url.searchParams.get('tagOp') === 'OR' ? 'OR' : 'AND';
|
||||||
|
const page = Math.max(0, Number(url.searchParams.get('page') ?? '0') || 0);
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
@@ -44,14 +47,19 @@ export async function load({ url, fetch }) {
|
|||||||
tagQ: tagQ && !tags.length ? tagQ : undefined,
|
tagQ: tagQ && !tags.length ? tagQ : undefined,
|
||||||
tagOp: tagOp === 'OR' ? 'OR' : undefined,
|
tagOp: tagOp === 'OR' ? 'OR' : undefined,
|
||||||
sort,
|
sort,
|
||||||
dir: dir || undefined
|
dir: dir || undefined,
|
||||||
|
page,
|
||||||
|
size: PAGE_SIZE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
items: [] as DocumentSearchItem[],
|
items: [] as DocumentSearchItem[],
|
||||||
total: 0,
|
totalElements: 0,
|
||||||
|
pageNumber: 0,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
totalPages: 0,
|
||||||
q,
|
q,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
@@ -77,7 +85,10 @@ export async function load({ url, fetch }) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
items: (result.data?.items ?? []) as DocumentSearchItem[],
|
items: (result.data?.items ?? []) as DocumentSearchItem[],
|
||||||
total: result.data?.total ?? 0,
|
totalElements: result.data?.totalElements ?? 0,
|
||||||
|
pageNumber: result.data?.pageNumber ?? page,
|
||||||
|
pageSize: result.data?.pageSize ?? PAGE_SIZE,
|
||||||
|
totalPages: result.data?.totalPages ?? 0,
|
||||||
q,
|
q,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { untrack } from 'svelte';
|
|||||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||||
import SearchFilterBar from '../SearchFilterBar.svelte';
|
import SearchFilterBar from '../SearchFilterBar.svelte';
|
||||||
import DocumentList from '../DocumentList.svelte';
|
import DocumentList from '../DocumentList.svelte';
|
||||||
|
import Pagination from '$lib/components/Pagination.svelte';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -35,6 +36,12 @@ let showAdvanced = $state(untrack(hasAdvancedFilters));
|
|||||||
|
|
||||||
let searchTimer: ReturnType<typeof setTimeout>;
|
let searchTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds the URL from the CURRENT local filter state. `page` is intentionally
|
||||||
|
* not carried over — any filter change implicitly resets back to page 0, which
|
||||||
|
* is the expected behaviour. For page-only navigation use {@link buildPageHref}
|
||||||
|
* instead, which preserves every filter from the server `data`.
|
||||||
|
*/
|
||||||
function triggerSearch() {
|
function triggerSearch() {
|
||||||
const params = new SvelteURLSearchParams();
|
const params = new SvelteURLSearchParams();
|
||||||
if (q) params.set('q', q);
|
if (q) params.set('q', q);
|
||||||
@@ -50,6 +57,29 @@ function triggerSearch() {
|
|||||||
goto(`/documents?${params.toString()}`, { keepFocus: true, noScroll: true });
|
goto(`/documents?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the href for a Pagination prev/next link. Preserves every current
|
||||||
|
* filter param and only updates `page`. Uses a normal <a href> (not goto)
|
||||||
|
* so SvelteKit's default scroll restoration brings the user to the top of
|
||||||
|
* the new slice — the expected behaviour for page navigation.
|
||||||
|
*/
|
||||||
|
function buildPageHref(targetPage: number): string {
|
||||||
|
const params = new SvelteURLSearchParams();
|
||||||
|
if (data.q) params.set('q', data.q);
|
||||||
|
if (data.from) params.set('from', data.from);
|
||||||
|
if (data.to) params.set('to', data.to);
|
||||||
|
if (data.senderId) params.set('senderId', data.senderId);
|
||||||
|
if (data.receiverId) params.set('receiverId', data.receiverId);
|
||||||
|
(data.tags || []).forEach((t: string) => params.append('tag', t));
|
||||||
|
if (data.sort) params.set('sort', data.sort);
|
||||||
|
if (data.dir) params.set('dir', data.dir);
|
||||||
|
if (data.tagQ) params.set('tagQ', data.tagQ);
|
||||||
|
if (data.tagOp === 'OR') params.set('tagOp', 'OR');
|
||||||
|
if (targetPage > 0) params.set('page', String(targetPage));
|
||||||
|
const qs = params.toString();
|
||||||
|
return qs ? `/documents?${qs}` : '/documents';
|
||||||
|
}
|
||||||
|
|
||||||
function handleTextSearch() {
|
function handleTextSearch() {
|
||||||
clearTimeout(searchTimer);
|
clearTimeout(searchTimer);
|
||||||
searchTimer = setTimeout(() => triggerSearch(), 500);
|
searchTimer = setTimeout(() => triggerSearch(), 500);
|
||||||
@@ -115,10 +145,12 @@ $effect(() => {
|
|||||||
|
|
||||||
<DocumentList
|
<DocumentList
|
||||||
items={data.items}
|
items={data.items}
|
||||||
total={data.total}
|
total={data.totalElements}
|
||||||
q={data.q}
|
q={data.q}
|
||||||
canWrite={data.canWrite}
|
canWrite={data.canWrite}
|
||||||
error={data.error}
|
error={data.error}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe('documents page load — search params', () => {
|
|||||||
it('passes q, from, to to the search API', async () => {
|
it('passes q, from, to to the search API', async () => {
|
||||||
const mockGet = vi.fn().mockResolvedValue({
|
const mockGet = vi.fn().mockResolvedValue({
|
||||||
response: { ok: true, status: 200 },
|
response: { ok: true, status: 200 },
|
||||||
data: { items: [], total: 0 }
|
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||||
});
|
});
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
@@ -49,7 +49,7 @@ describe('documents page load — search params', () => {
|
|||||||
it('passes senderId and receiverId to the search API', async () => {
|
it('passes senderId and receiverId to the search API', async () => {
|
||||||
const mockGet = vi.fn().mockResolvedValue({
|
const mockGet = vi.fn().mockResolvedValue({
|
||||||
response: { ok: true, status: 200 },
|
response: { ok: true, status: 200 },
|
||||||
data: { items: [], total: 0 }
|
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||||
});
|
});
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
@@ -73,7 +73,7 @@ describe('documents page load — search params', () => {
|
|||||||
it('passes sort, dir, tagQ to the search API', async () => {
|
it('passes sort, dir, tagQ to the search API', async () => {
|
||||||
const mockGet = vi.fn().mockResolvedValue({
|
const mockGet = vi.fn().mockResolvedValue({
|
||||||
response: { ok: true, status: 200 },
|
response: { ok: true, status: 200 },
|
||||||
data: { items: [], total: 0 }
|
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||||
});
|
});
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
@@ -103,7 +103,7 @@ describe('documents page load — search params', () => {
|
|||||||
};
|
};
|
||||||
const mockGet = vi.fn().mockResolvedValue({
|
const mockGet = vi.fn().mockResolvedValue({
|
||||||
response: { ok: true, status: 200 },
|
response: { ok: true, status: 200 },
|
||||||
data: { items: [item], total: 42 }
|
data: { items: [item], totalElements: 42, pageNumber: 0, pageSize: 50, totalPages: 1 }
|
||||||
});
|
});
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
@@ -115,13 +115,13 @@ describe('documents page load — search params', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.items).toHaveLength(1);
|
expect(result.items).toHaveLength(1);
|
||||||
expect(result.total).toBe(42);
|
expect(result.totalElements).toBe(42);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns filter values in the result for pre-filling the UI', async () => {
|
it('returns filter values in the result for pre-filling the UI', async () => {
|
||||||
const mockGet = vi.fn().mockResolvedValue({
|
const mockGet = vi.fn().mockResolvedValue({
|
||||||
response: { ok: true, status: 200 },
|
response: { ok: true, status: 200 },
|
||||||
data: { items: [], total: 0 }
|
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||||
});
|
});
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
|
|||||||
@@ -118,4 +118,20 @@ describe('documents page — URL building', () => {
|
|||||||
expect.objectContaining({ keepFocus: true, noScroll: true })
|
expect.objectContaining({ keepFocus: true, noScroll: true })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filter change does not carry the current page — goto URL drops page param', async () => {
|
||||||
|
const { goto } = await import('$app/navigation');
|
||||||
|
vi.mocked(goto).mockClear();
|
||||||
|
|
||||||
|
// User is mid-way through results at page 5; change the search text.
|
||||||
|
render(Page, { data: makeData({ q: 'old', pageNumber: 5 }) });
|
||||||
|
|
||||||
|
const input = page.getByRole('textbox', { name: SEARCH_LABEL });
|
||||||
|
await input.fill('Brief');
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
|
||||||
|
const [url] = vi.mocked(goto).mock.calls[0];
|
||||||
|
expect(url).toContain('q=Brief');
|
||||||
|
expect(url).not.toContain('page=');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user