Feature/269 paginate catalogue #286

Merged
bermudalamb merged 4 commits from feature/269-paginate-catalogue into main 2026-09-03 16:29:42 -05:00
2 changed files with 257 additions and 0 deletions
Showing only changes of commit 013b6abb52 - Show all commits
+101
View File
@@ -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<T>(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<Storage, 'getItem'> | 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<Storage, 'setItem'> | 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.
}
}
+156
View File
@@ -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<string, string> = {}) {
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();
});
});