Feature/269 paginate catalogue #286
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user