diff --git a/frontend/src/pagination.ts b/frontend/src/pagination.ts new file mode 100644 index 0000000..60c4bf7 --- /dev/null +++ b/frontend/src/pagination.ts @@ -0,0 +1,101 @@ +/** + * Paging the catalogue: every decision worth testing, as pure functions. + * + * Pure because that is the only thing this project can unit-test — vitest runs + * in a node environment with no jsdom and no testing-library, so a hook or a + * component can only be exercised through Playwright. Keeping the rules here + * means the rules have tests and the React wrapper is thin enough not to need + * any. Same split `filters.ts` already uses for the URL. + * + * Paging is client-side. `GET /api/items` still returns everything and the + * client still asks for everything; moving it server-side is a separate change + * and should be measured rather than assumed, since the filter endpoints + * already do work per request. See #269. + */ + +/** + * Ten, because it is the conservative default and someone who wants a denser + * grid can say so once and be remembered. + */ +export const DEFAULT_PAGE_SIZE = 10; + +/** The sizes offered in the control. A value outside this list is not honoured. */ +export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const; + +export const PAGE_SIZE_STORAGE_KEY = 'catalogue:pageSize'; + +/** + * A page size, from whatever was supplied. + * + * Checked against the list rather than merely parsed as a number. A stored or + * hand-edited 5000 would render the entire catalogue in one page, which is the + * exact failure #269 exists to prevent, so an unrecognised size is refused + * rather than clamped — clamping would quietly honour a value nobody offered. + */ +export function readPageSize(raw: string | null): number { + const parsed = Number(raw); + const allowed = (PAGE_SIZE_OPTIONS as readonly number[]).includes(parsed); + return raw !== null && raw !== '' && allowed ? parsed : DEFAULT_PAGE_SIZE; +} + +/** + * The page a URL asks for. + * + * Falls back to the first page for anything unreadable, which is the rule + * `filtersFromSearchParams` already applies to a mangled filter: a bad link + * lands somewhere sensible rather than on an error. + */ +export function pageFromSearchParams(params: URLSearchParams): number { + const parsed = Number(params.get('page')); + return Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : 1; +} + +/** + * The page actually shown, given how much there is to show. + * + * A shared link to page 7 of a catalogue that has since shrunk to two pages + * shows the last page rather than an empty grid — an empty page would read as + * "this shop has nothing", which is the confusion the empty state exists to + * avoid. + */ +export function clampPage(page: number, total: number, pageSize: number): number { + const lastPage = Math.max(1, Math.ceil(total / pageSize)); + return Math.min(Math.max(1, page), lastPage); +} + +/** One page of items. Past the end this is empty rather than an error. */ +export function pageSlice(items: readonly T[], page: number, pageSize: number): T[] { + const start = (page - 1) * pageSize; + return items.slice(start, start + pageSize); +} + +/** + * The remembered page size. + * + * Every access is guarded. `localStorage` is absent when there is no window at + * all and throws outright in some privacy modes, and neither is a reason for a + * customer to lose the catalogue — the worst acceptable outcome of a broken + * preference is the default. + */ +export function readStoredPageSize(storage: Pick | null): number { + if (storage === null) return DEFAULT_PAGE_SIZE; + try { + return readPageSize(storage.getItem(PAGE_SIZE_STORAGE_KEY)); + } catch { + return DEFAULT_PAGE_SIZE; + } +} + +/** Remembers a choice, or quietly does not. See `readStoredPageSize`. */ +export function writeStoredPageSize( + storage: Pick | null, + size: number +): void { + if (storage === null) return; + try { + storage.setItem(PAGE_SIZE_STORAGE_KEY, String(size)); + } catch { + // A preference that cannot be remembered is not worth an error. The + // customer's current page size still works for this visit. + } +} diff --git a/frontend/tests/unit/pagination.test.ts b/frontend/tests/unit/pagination.test.ts new file mode 100644 index 0000000..ebe17e4 --- /dev/null +++ b/frontend/tests/unit/pagination.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from 'vitest'; +import { + DEFAULT_PAGE_SIZE, + PAGE_SIZE_OPTIONS, + PAGE_SIZE_STORAGE_KEY, + clampPage, + pageFromSearchParams, + pageSlice, + readPageSize, + readStoredPageSize, + writeStoredPageSize +} from '../../src/pagination'; + +describe('the page size a customer may choose', () => { + it('defaults to 10, the conservative choice', () => { + expect(DEFAULT_PAGE_SIZE).toBe(10); + }); + + it('offers exactly the four sizes the issue settled on', () => { + expect([...PAGE_SIZE_OPTIONS]).toEqual([10, 20, 50, 100]); + }); + + it('accepts a size that is on the list', () => { + expect(readPageSize('50')).toBe(50); + }); + + // Anything else falls back rather than being honoured. A hand-edited value + // of 5000 would render the whole catalogue, which is the thing this issue + // exists to stop. + it('falls back to the default for a size that is not on the list', () => { + expect(readPageSize('5000')).toBe(DEFAULT_PAGE_SIZE); + expect(readPageSize('7')).toBe(DEFAULT_PAGE_SIZE); + }); + + it('falls back to the default for nonsense and for nothing at all', () => { + expect(readPageSize('abc')).toBe(DEFAULT_PAGE_SIZE); + expect(readPageSize('')).toBe(DEFAULT_PAGE_SIZE); + expect(readPageSize(null)).toBe(DEFAULT_PAGE_SIZE); + }); +}); + +describe('the page number in the URL', () => { + it('is 1 when the parameter is absent', () => { + expect(pageFromSearchParams(new URLSearchParams())).toBe(1); + }); + + it('reads a sensible page', () => { + expect(pageFromSearchParams(new URLSearchParams('page=3'))).toBe(3); + }); + + // A mangled link falls back to the first page rather than to an empty view, + // the same rule filtersFromSearchParams follows for a mangled filter. + it('falls back to 1 for zero, negatives and nonsense', () => { + expect(pageFromSearchParams(new URLSearchParams('page=0'))).toBe(1); + expect(pageFromSearchParams(new URLSearchParams('page=-4'))).toBe(1); + expect(pageFromSearchParams(new URLSearchParams('page=abc'))).toBe(1); + expect(pageFromSearchParams(new URLSearchParams('page=2.5'))).toBe(1); + }); +}); + +describe('keeping a page number inside the result set', () => { + // The case that matters: someone shares a link to page 7, the catalogue + // shrinks, and the link must show the last page rather than nothing at all. + it('clamps a page past the end back to the last page', () => { + expect(clampPage(7, 25, 10)).toBe(3); + }); + + it('leaves a page inside the range alone', () => { + expect(clampPage(2, 25, 10)).toBe(2); + }); + + // An empty result set still has a first page to show the empty state on. + it('is 1 when there is nothing to show', () => { + expect(clampPage(3, 0, 10)).toBe(1); + }); + + it('is 1 when everything fits on one page', () => { + expect(clampPage(4, 6, 10)).toBe(1); + }); +}); + +describe('taking one page out of the items', () => { + const items = Array.from({ length: 25 }, (_, i) => i + 1); + + it('takes the first page', () => { + expect(pageSlice(items, 1, 10)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + }); + + it('takes a middle page', () => { + expect(pageSlice(items, 2, 10)).toEqual([11, 12, 13, 14, 15, 16, 17, 18, 19, 20]); + }); + + it('takes a short last page', () => { + expect(pageSlice(items, 3, 10)).toEqual([21, 22, 23, 24, 25]); + }); + + it('is empty past the end rather than throwing', () => { + expect(pageSlice(items, 9, 10)).toEqual([]); + }); +}); + +/** A Storage that is present and works. */ +function fakeStorage(initial: Record = {}) { + const data = { ...initial }; + return { + getItem: (key: string) => data[key] ?? null, + setItem: (key: string, value: string) => { + data[key] = value; + }, + read: () => data + }; +} + +describe('remembering the choice', () => { + it('reads a stored size', () => { + expect(readStoredPageSize(fakeStorage({ [PAGE_SIZE_STORAGE_KEY]: '20' }))).toBe(20); + }); + + it('defaults when nothing was stored', () => { + expect(readStoredPageSize(fakeStorage())).toBe(DEFAULT_PAGE_SIZE); + }); + + it('writes the choice under the agreed key', () => { + const storage = fakeStorage(); + writeStoredPageSize(storage, 50); + expect(storage.read()[PAGE_SIZE_STORAGE_KEY]).toBe('50'); + }); + + // Storage is absent during server rendering and throws outright in some + // privacy modes. Neither may take the catalogue down over a preference. + it('defaults rather than throwing when storage is missing', () => { + expect(readStoredPageSize(null)).toBe(DEFAULT_PAGE_SIZE); + }); + + it('defaults rather than throwing when storage refuses to be read', () => { + const hostile = { + getItem: () => { + throw new Error('access denied'); + } + }; + expect(readStoredPageSize(hostile)).toBe(DEFAULT_PAGE_SIZE); + }); + + it('gives up quietly when storage refuses to be written', () => { + const hostile = { + setItem: () => { + throw new Error('quota exceeded'); + } + }; + expect(() => writeStoredPageSize(hostile, 20)).not.toThrow(); + }); + + it('does nothing when there is no storage to write to', () => { + expect(() => writeStoredPageSize(null, 20)).not.toThrow(); + }); +});