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
Showing only changes of commit f9073c515e - Show all commits
+89 -8
View File
@@ -10,6 +10,7 @@ import theme from 'antd/es/theme';
import Badge from 'antd/es/badge'; import Badge from 'antd/es/badge';
import Empty from 'antd/es/empty'; import Empty from 'antd/es/empty';
import Alert from 'antd/es/alert'; import Alert from 'antd/es/alert';
import Pagination from 'antd/es/pagination';
import { ShoppingCartOutlined } from '@ant-design/icons'; import { ShoppingCartOutlined } from '@ant-design/icons';
import { Link, useLocation, useSearchParams } from 'react-router-dom'; import { Link, useLocation, useSearchParams } from 'react-router-dom';
import { Item } from './api'; import { Item } from './api';
@@ -18,6 +19,14 @@ import ItemCard from './components/ItemCard';
import BrandMark from './components/BrandMark'; import BrandMark from './components/BrandMark';
import FilterBar from './components/filters/FilterBar'; import FilterBar from './components/filters/FilterBar';
import { chipsFor } from './components/filters/dimension'; import { chipsFor } from './components/filters/dimension';
import {
PAGE_SIZE_OPTIONS,
clampPage,
pageFromSearchParams,
pageSlice,
readStoredPageSize,
writeStoredPageSize
} from './pagination';
import { import {
availabilityDimension, availabilityDimension,
categoryDimension, categoryDimension,
@@ -40,7 +49,14 @@ const { Title } = Typography;
type CatalogueProps = Readonly<{ type CatalogueProps = Readonly<{
failed: boolean; failed: boolean;
loading: boolean; loading: boolean;
/** One page of items, not the whole result set — see `total`. */
items: Item[]; items: Item[];
/** How many items match the filters altogether, across every page. */
total: number;
page: number;
pageSize: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
filtered: boolean; filtered: boolean;
needsFavoritesAuth: boolean; needsFavoritesAuth: boolean;
onRetry: () => void; onRetry: () => void;
@@ -58,6 +74,11 @@ function Catalogue({
failed, failed,
loading, loading,
items, items,
total,
page,
pageSize,
onPageChange,
onPageSizeChange,
filtered, filtered,
needsFavoritesAuth, needsFavoritesAuth,
onRetry, onRetry,
@@ -98,13 +119,32 @@ function Catalogue({
} }
return ( return (
<Row gutter={[20, 20]}> <>
{items.map(item => ( <Row gutter={[20, 20]}>
<Col key={item.id} xs={24} sm={12} md={8} lg={6}> {items.map(item => (
<ItemCard item={item} onChanged={onChanged} /> <Col key={item.id} xs={24} sm={12} md={8} lg={6}>
</Col> <ItemCard item={item} onChanged={onChanged} />
))} </Col>
</Row> ))}
</Row>
<Pagination
style={{ marginTop: 24, textAlign: 'center' }}
current={page}
pageSize={pageSize}
total={total}
onChange={onPageChange}
showSizeChanger
pageSizeOptions={[...PAGE_SIZE_OPTIONS]}
onShowSizeChange={(_current, size) => onPageSizeChange(size)}
// Deliberately off: the jump box earns its place on a table of
// thousands of rows, not on a catalogue somebody is browsing (#269).
showQuickJumper={false}
// Shown even when everything fits on one page, because the count is a
// requirement in its own right and hiding the control would hide it.
hideOnSinglePage={false}
showTotal={(count) => `${count} ${count === 1 ? 'item' : 'items'}`}
/>
</>
); );
} }
@@ -163,6 +203,42 @@ export default function App() {
const { items, loading, failed, options, needsFavoritesAuth, reload, retry, filterKey } = const { items, loading, failed, options, needsFavoritesAuth, reload, retry, filterKey } =
useCatalogue(filters, openAuthModal); useCatalogue(filters, openAuthModal);
/**
* The page size is a preference, not view state, so it lives in storage
* rather than in the URL. Putting it in the URL would mean sharing a link to
* an item also imposed your page size on whoever opened it, which is not
* yours to decide for them. Read through a lazy initialiser so a storage
* that throws is not hit on every render.
*/
const [pageSize, setPageSize] = useState(() =>
readStoredPageSize(typeof window === 'undefined' ? null : window.localStorage)
);
const choosePageSize = useCallback((size: number) => {
setPageSize(size);
writeStoredPageSize(typeof window === 'undefined' ? null : window.localStorage, size);
}, []);
// Clamped against what there actually is, so a shared link to a page that no
// longer exists shows the last page rather than an empty grid.
const page = clampPage(pageFromSearchParams(searchParams), items.length, pageSize);
const visibleItems = useMemo(() => pageSlice(items, page, pageSize), [items, page, pageSize]);
const goToPage = useCallback(
(next: number) => {
const params = new URLSearchParams(searchParams);
// Page one is the absence of the parameter, so the plain catalogue URL
// stays clean and two links to the same first page are the same string.
if (next <= 1) params.delete('page');
else params.set('page', String(next));
// push, not replace: paging is navigation, and the back button should
// return to the page you came from. Filters use replace for the opposite
// reason — dragging a slider must not bury the previous view.
setSearchParams(params);
},
[searchParams, setSearchParams]
);
const applyFilters = useCallback( const applyFilters = useCallback(
(next: ItemFilters) => { (next: ItemFilters) => {
// replace, not push: dragging a slider shouldn't bury the previous page // replace, not push: dragging a slider shouldn't bury the previous page
@@ -264,7 +340,12 @@ export default function App() {
<Catalogue <Catalogue
failed={failed} failed={failed}
loading={loading} loading={loading}
items={items} items={visibleItems}
total={items.length}
page={page}
pageSize={pageSize}
onPageChange={goToPage}
onPageSizeChange={choosePageSize}
filtered={filtered} filtered={filtered}
needsFavoritesAuth={needsFavoritesAuth} needsFavoritesAuth={needsFavoritesAuth}
onRetry={retry} onRetry={retry}