Feature/269 paginate catalogue #286
+89
-8
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user