Feature/269 paginate catalogue #286
+82
-1
@@ -10,6 +10,7 @@ import theme from 'antd/es/theme';
|
||||
import Badge from 'antd/es/badge';
|
||||
import Empty from 'antd/es/empty';
|
||||
import Alert from 'antd/es/alert';
|
||||
import Pagination from 'antd/es/pagination';
|
||||
import { ShoppingCartOutlined } from '@ant-design/icons';
|
||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { Item } from './api';
|
||||
@@ -18,6 +19,14 @@ import ItemCard from './components/ItemCard';
|
||||
import BrandMark from './components/BrandMark';
|
||||
import FilterBar from './components/filters/FilterBar';
|
||||
import { chipsFor } from './components/filters/dimension';
|
||||
import {
|
||||
PAGE_SIZE_OPTIONS,
|
||||
clampPage,
|
||||
pageFromSearchParams,
|
||||
pageSlice,
|
||||
readStoredPageSize,
|
||||
writeStoredPageSize
|
||||
} from './pagination';
|
||||
import {
|
||||
availabilityDimension,
|
||||
categoryDimension,
|
||||
@@ -40,7 +49,14 @@ const { Title } = Typography;
|
||||
type CatalogueProps = Readonly<{
|
||||
failed: boolean;
|
||||
loading: boolean;
|
||||
/** One page of items, not the whole result set — see `total`. */
|
||||
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;
|
||||
needsFavoritesAuth: boolean;
|
||||
onRetry: () => void;
|
||||
@@ -58,6 +74,11 @@ function Catalogue({
|
||||
failed,
|
||||
loading,
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
filtered,
|
||||
needsFavoritesAuth,
|
||||
onRetry,
|
||||
@@ -98,6 +119,7 @@ function Catalogue({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row gutter={[20, 20]}>
|
||||
{items.map(item => (
|
||||
<Col key={item.id} xs={24} sm={12} md={8} lg={6}>
|
||||
@@ -105,6 +127,24 @@ function Catalogue({
|
||||
</Col>
|
||||
))}
|
||||
</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 } =
|
||||
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(
|
||||
(next: ItemFilters) => {
|
||||
// replace, not push: dragging a slider shouldn't bury the previous page
|
||||
@@ -264,7 +340,12 @@ export default function App() {
|
||||
<Catalogue
|
||||
failed={failed}
|
||||
loading={loading}
|
||||
items={items}
|
||||
items={visibleItems}
|
||||
total={items.length}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={goToPage}
|
||||
onPageSizeChange={choosePageSize}
|
||||
filtered={filtered}
|
||||
needsFavoritesAuth={needsFavoritesAuth}
|
||||
onRetry={retry}
|
||||
|
||||
Reference in New Issue
Block a user