Two assertions named a fixture and expected it visible in the unfiltered grid. No paginated catalogue can promise that — the item is on some page, not necessarily the first — so both would have started failing the moment paging landed. They were only ever proxies for "the result set got bigger", and the visible total lets them say that directly, which is what the issue predicted when it asked for a count. The new cases assert the control and the URL rather than which item is on which page, because the development database never truncates and which item lands where is not something a test may rely on. That is the same trap the two rewritten assertions had fallen into, and repeating it in new tests would have been worse than leaving them alone. Writing them found a real defect rather than just covering the feature. The control was rendering while the catalogue was still loading, showing "0 items" for a moment before the real count arrived — the empty-state early return only fires once loading has finished, so a mid-load render fell through to the grid branch with a total of zero. It is now suppressed until there is something to count, which is both true and what makes the count usable as a signal in a test. StorefrontPage.totalItems waits for the control for the same reason: reading during the load returned zero and quietly made "the result set shrank" compare against nothing. The conditional skips carry a file-level eslint exception with its reasoning rather than being left to add four warnings. They are honest about a real limit: against a catalogue of ten items or fewer these cases prove nothing, and if the e2e database is ever seeded that thinly they need fixtures of their own instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
380 lines
14 KiB
TypeScript
Executable File
380 lines
14 KiB
TypeScript
Executable File
import { useState, useCallback, useMemo } from 'react';
|
|
import Layout from 'antd/es/layout';
|
|
import Typography from 'antd/es/typography';
|
|
import Switch from 'antd/es/switch';
|
|
import Row from 'antd/es/row';
|
|
import Col from 'antd/es/col';
|
|
import Spin from 'antd/es/spin';
|
|
import Button from 'antd/es/button';
|
|
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';
|
|
import { useCatalogue } from './useCatalogue';
|
|
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,
|
|
favoritesDimension,
|
|
priceDimension,
|
|
tagDimension
|
|
} from './components/filters/standardDimensions';
|
|
import { ItemFilters, filtersFromSearchParams, filtersToSearchParams } from './filters';
|
|
import AuthPromptModal from './customer/AuthPromptModal';
|
|
import { useThemeMode } from './theme/ThemeContext';
|
|
import { useCustomerAuth } from './customer/CustomerAuthContext';
|
|
import { useCart } from './cart/CartContext';
|
|
import ErrorBoundary from './components/ErrorBoundary';
|
|
import ErrorFallback from './components/ErrorFallback';
|
|
import DevThrow from './components/DevThrow';
|
|
|
|
const { Header, Content, Footer } = Layout;
|
|
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;
|
|
onSignIn: () => void;
|
|
onClearFilters: () => void;
|
|
onChanged: () => void;
|
|
}>;
|
|
|
|
// The body of the catalogue: an outage, a sign-in prompt, an empty state, or
|
|
// the grid. Extracted from App so the four cases read as early returns rather
|
|
// than a ternary chain, and because a function's cognitive complexity counts
|
|
// everything nested inside it — leaving this inline is what put App over the
|
|
// limit.
|
|
function Catalogue({
|
|
failed,
|
|
loading,
|
|
items,
|
|
total,
|
|
page,
|
|
pageSize,
|
|
onPageChange,
|
|
onPageSizeChange,
|
|
filtered,
|
|
needsFavoritesAuth,
|
|
onRetry,
|
|
onSignIn,
|
|
onClearFilters,
|
|
onChanged
|
|
}: CatalogueProps) {
|
|
if (failed) {
|
|
return (
|
|
<Alert
|
|
type="error"
|
|
showIcon
|
|
message="Couldn't load items"
|
|
description="The server didn't return the catalogue. This is usually temporary."
|
|
action={<Button size="small" onClick={onRetry}>Retry</Button>}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Prompted rather than requested: see the effect in App that sets this.
|
|
if (needsFavoritesAuth) {
|
|
return (
|
|
<Empty description="Sign in to see the items you have favorited">
|
|
<Button type="primary" onClick={onSignIn}>Sign in</Button>
|
|
<Button style={{ marginInlineStart: 8 }} onClick={onClearFilters}>Browse everything</Button>
|
|
</Empty>
|
|
);
|
|
}
|
|
|
|
if (!loading && !items.length) {
|
|
// Distinguished so "no items match these filters" never reads as an empty
|
|
// shop, and so the way out is offered only when there is one.
|
|
return (
|
|
<Empty description={filtered ? 'No items match these filters' : 'No items yet — check back soon'}>
|
|
{filtered ? <Button onClick={onClearFilters}>Clear filters</Button> : null}
|
|
</Empty>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Row gutter={[20, 20]}>
|
|
{items.map(item => (
|
|
<Col key={item.id} xs={24} sm={12} md={8} lg={6}>
|
|
<ItemCard item={item} onChanged={onChanged} />
|
|
</Col>
|
|
))}
|
|
</Row>
|
|
{/* Not while there is nothing to count. A genuinely empty catalogue
|
|
returns early above with the empty state, so the only way to reach
|
|
here with a total of zero is mid-load — and flashing "0 items" at
|
|
somebody while their catalogue is still arriving says something
|
|
untrue. */}
|
|
{total > 0 && (
|
|
<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'}`}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Availability first and always visible, because it is the coarsest cut and
|
|
// worth seeing without opening anything. Favorites next, so someone who came
|
|
// for their favorites does not scroll past the catalogue controls.
|
|
const STOREFRONT_DIMENSIONS = [
|
|
availabilityDimension,
|
|
favoritesDimension,
|
|
categoryDimension,
|
|
tagDimension,
|
|
priceDimension
|
|
];
|
|
|
|
// Hoisted out of the component that used to declare it inline.
|
|
//
|
|
// S6478 flags a function-returning-JSX in a prop as "defining a component
|
|
// during render". Here it is a render prop — ErrorBoundary's `fallback` is
|
|
// typed `(error: Error) => React.ReactNode` and called as
|
|
// `this.props.fallback(...)` — so React only ever sees the returned elements,
|
|
// never a new component type, and the subtree destruction the rule warns about
|
|
// does not happen. The rule's own message offers `allowAsProps` for exactly
|
|
// this shape, which cannot be set from here.
|
|
//
|
|
// Hoisting rather than suppressing because it costs nothing: these close over
|
|
// nothing local, so at module level they are one stable function instead of a
|
|
// new closure per render, which is mildly better and not a contortion. See #181.
|
|
function catalogueErrorFallback(error: Error) {
|
|
return (
|
|
<ErrorFallback
|
|
error={error}
|
|
title="The item list didn't load"
|
|
actions={
|
|
<Button type="primary" onClick={() => window.location.reload()}>
|
|
Reload
|
|
</Button>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export default function App() {
|
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const location = useLocation();
|
|
const { mode, toggle } = useThemeMode();
|
|
const { customer } = useCustomerAuth();
|
|
const { items: cartItems } = useCart();
|
|
const { token } = theme.useToken();
|
|
|
|
// The URL is the single source of truth for filter state, so a reload, a
|
|
// shared link, and the back button all restore the same view.
|
|
const filters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams]);
|
|
const openAuthModal = useCallback(() => setAuthModalOpen(true), []);
|
|
|
|
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
|
|
// under dozens of history entries.
|
|
setSearchParams(filtersToSearchParams(next), { replace: true });
|
|
},
|
|
[setSearchParams]
|
|
);
|
|
|
|
const clearFilters = useCallback(() => {
|
|
setSearchParams(new URLSearchParams(), { replace: true });
|
|
}, [setSearchParams]);
|
|
|
|
// The parent needs to know whether anything is filtering — for the empty
|
|
// state's wording — but has no chip row of its own to count. Through the same
|
|
// chipsFor call FilterBar's tally goes through, not a second expression over
|
|
// the same dimensions: those agreed only by convention, which is the defect
|
|
// #188 exists to remove.
|
|
const filterContext = {
|
|
filters,
|
|
onChange: applyFilters,
|
|
categories: options?.categories ?? [],
|
|
tags: options?.tags ?? [],
|
|
priceRange: options?.priceRange ?? null
|
|
};
|
|
const filtered = chipsFor(STOREFRONT_DIMENSIONS, filterContext).length > 0;
|
|
|
|
return (
|
|
<Layout style={{ minHeight: '100vh' }}>
|
|
<Header
|
|
className="site-header"
|
|
style={{
|
|
background: token.colorBgContainer,
|
|
borderBottom: `1px solid ${token.colorBorderSecondary}`
|
|
}}
|
|
>
|
|
{/* Wrapped so the mark and the wordmark travel together — the title
|
|
keeps its own ellipsis behaviour when the header gets narrow, and the
|
|
mark is never what gets truncated. */}
|
|
<div className="site-header-brand" style={{ color: token.colorText }}>
|
|
<BrandMark size={28} />
|
|
<Title level={3} className="site-header-title" style={{ color: token.colorText }}>
|
|
Redefined Designs
|
|
</Title>
|
|
</div>
|
|
<div className="site-header-actions">
|
|
<Switch checked={mode === 'dark'} onChange={toggle} checkedChildren="Dark" unCheckedChildren="Light" />
|
|
<Link to="/cart">
|
|
<Badge count={cartItems.length} size="small">
|
|
<Button icon={<ShoppingCartOutlined />} />
|
|
</Badge>
|
|
</Link>
|
|
{/* The account link names the page to render behind the modal, so
|
|
closing it comes back here — filters and all — rather than to a
|
|
default. */}
|
|
{customer ? (
|
|
<Link to="/account" state={{ background: location }}>
|
|
<Button>My Account</Button>
|
|
</Link>
|
|
) : (
|
|
<>
|
|
<Link to="/login" state={{ background: location }}>
|
|
<Button>Log in</Button>
|
|
</Link>
|
|
<Link to="/register" state={{ background: location }}>
|
|
<Button type="primary">Sign up</Button>
|
|
</Link>
|
|
</>
|
|
)}
|
|
</div>
|
|
</Header>
|
|
<Content style={{ padding: 24 }}>
|
|
<div className="filter-bar">
|
|
<FilterBar
|
|
dimensions={STOREFRONT_DIMENSIONS}
|
|
filters={filters}
|
|
onChange={applyFilters}
|
|
onClear={clearFilters}
|
|
categories={options?.categories ?? []}
|
|
tags={options?.tags ?? []}
|
|
priceRange={options?.priceRange ?? null}
|
|
resultCount={items.length}
|
|
/>
|
|
</div>
|
|
|
|
{loading && !items.length && !failed ? <Spin /> : null}
|
|
<ErrorBoundary
|
|
context="catalogue"
|
|
// Lets Clear filters (and any other filter change) recover the grid
|
|
// without a reload: filterKey changes whenever the filters change,
|
|
// which resets the boundary the next time it renders. `?boom=catalogue`
|
|
// is not itself a known filter, so filterKey is unaffected by it and
|
|
// the existing end-to-end assertion that the fallback renders still
|
|
// holds.
|
|
resetKey={filterKey}
|
|
fallback={catalogueErrorFallback}
|
|
>
|
|
{import.meta.env.DEV && <DevThrow scope="catalogue" />}
|
|
<Catalogue
|
|
failed={failed}
|
|
loading={loading}
|
|
items={visibleItems}
|
|
total={items.length}
|
|
page={page}
|
|
pageSize={pageSize}
|
|
onPageChange={goToPage}
|
|
onPageSizeChange={choosePageSize}
|
|
filtered={filtered}
|
|
needsFavoritesAuth={needsFavoritesAuth}
|
|
onRetry={retry}
|
|
onSignIn={openAuthModal}
|
|
onClearFilters={clearFilters}
|
|
onChanged={reload}
|
|
/>
|
|
</ErrorBoundary>
|
|
</Content>
|
|
<Footer style={{ textAlign: 'center', background: token.colorBgContainer }}>
|
|
<Link to="/privacy">Privacy Policy</Link>
|
|
</Footer>
|
|
|
|
{/* The same prompt the heart button and Add to Cart use. Signing in
|
|
resolves the gate above, and the filter then applies on its own — the
|
|
customer never has to set it a second time. */}
|
|
<AuthPromptModal
|
|
open={authModalOpen}
|
|
onClose={() => setAuthModalOpen(false)}
|
|
onSuccess={() => setAuthModalOpen(false)}
|
|
/>
|
|
</Layout>
|
|
);
|
|
}
|