refactor(filters): compose the storefront from dimensions and delete the old components (#188)
Replaces App.tsx's hand-written Segmented/Filters-button/ActiveFilterChips region with a single FilterBar composed from five dimensions, and removes the FilterDrawer and ActiveFilterChips components along with the activeFilterCount and hasActiveFilters helpers they were the only callers of. Catalogue now receives a filtered boolean computed the same way FilterBar computes its own chip tally, rather than the ItemFilters object it only ever used for that one check.
This commit is contained in:
+40
-73
@@ -10,25 +10,21 @@ 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 Segmented from 'antd/es/segmented';
|
import { ShoppingCartOutlined } from '@ant-design/icons';
|
||||||
import { ShoppingCartOutlined, FilterOutlined } 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';
|
||||||
import { useCatalogue } from './useCatalogue';
|
import { useCatalogue } from './useCatalogue';
|
||||||
import ItemCard from './components/ItemCard';
|
import ItemCard from './components/ItemCard';
|
||||||
import BrandMark from './components/BrandMark';
|
import BrandMark from './components/BrandMark';
|
||||||
import FilterDrawer from './components/FilterDrawer';
|
import FilterBar from './components/filters/FilterBar';
|
||||||
import ActiveFilterChips from './components/ActiveFilterChips';
|
|
||||||
import {
|
import {
|
||||||
ItemFilters,
|
availabilityDimension,
|
||||||
SaleState,
|
categoryDimension,
|
||||||
STOREFRONT_SALE_STATUSES,
|
favoritesDimension,
|
||||||
activeFilterCount,
|
priceDimension,
|
||||||
filtersFromSearchParams,
|
tagDimension
|
||||||
filtersToSearchParams,
|
} from './components/filters/standardDimensions';
|
||||||
hasActiveFilters,
|
import { ItemFilters, filtersFromSearchParams, filtersToSearchParams } from './filters';
|
||||||
saleStateFromStatuses
|
|
||||||
} from './filters';
|
|
||||||
import AuthPromptModal from './customer/AuthPromptModal';
|
import AuthPromptModal from './customer/AuthPromptModal';
|
||||||
import { useThemeMode } from './theme/ThemeContext';
|
import { useThemeMode } from './theme/ThemeContext';
|
||||||
import { useCustomerAuth } from './customer/CustomerAuthContext';
|
import { useCustomerAuth } from './customer/CustomerAuthContext';
|
||||||
@@ -44,7 +40,7 @@ type CatalogueProps = Readonly<{
|
|||||||
failed: boolean;
|
failed: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
items: Item[];
|
items: Item[];
|
||||||
filters: ItemFilters;
|
filtered: boolean;
|
||||||
needsFavoritesAuth: boolean;
|
needsFavoritesAuth: boolean;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
onSignIn: () => void;
|
onSignIn: () => void;
|
||||||
@@ -61,7 +57,7 @@ function Catalogue({
|
|||||||
failed,
|
failed,
|
||||||
loading,
|
loading,
|
||||||
items,
|
items,
|
||||||
filters,
|
filtered,
|
||||||
needsFavoritesAuth,
|
needsFavoritesAuth,
|
||||||
onRetry,
|
onRetry,
|
||||||
onSignIn,
|
onSignIn,
|
||||||
@@ -93,7 +89,6 @@ function Catalogue({
|
|||||||
if (!loading && !items.length) {
|
if (!loading && !items.length) {
|
||||||
// Distinguished so "no items match these filters" never reads as an empty
|
// 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.
|
// shop, and so the way out is offered only when there is one.
|
||||||
const filtered = hasActiveFilters(filters);
|
|
||||||
return (
|
return (
|
||||||
<Empty description={filtered ? 'No items match these filters' : 'No items yet — check back soon'}>
|
<Empty description={filtered ? 'No items match these filters' : 'No items yet — check back soon'}>
|
||||||
{filtered ? <Button onClick={onClearFilters}>Clear filters</Button> : null}
|
{filtered ? <Button onClick={onClearFilters}>Clear filters</Button> : null}
|
||||||
@@ -112,8 +107,18 @@ function Catalogue({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
];
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
||||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -143,7 +148,17 @@ export default function App() {
|
|||||||
setSearchParams(new URLSearchParams(), { replace: true });
|
setSearchParams(new URLSearchParams(), { replace: true });
|
||||||
}, [setSearchParams]);
|
}, [setSearchParams]);
|
||||||
|
|
||||||
const activeCount = activeFilterCount(filters);
|
// 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. Computed the
|
||||||
|
// same way FilterBar computes its own tally, from the same dimensions.
|
||||||
|
const filterContext = {
|
||||||
|
filters,
|
||||||
|
onChange: applyFilters,
|
||||||
|
categories: options?.categories ?? [],
|
||||||
|
tags: options?.tags ?? [],
|
||||||
|
priceRange: options?.priceRange ?? null
|
||||||
|
};
|
||||||
|
const filtered = STOREFRONT_DIMENSIONS.some((dimension) => dimension.chips(filterContext).length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ minHeight: '100vh' }}>
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
@@ -191,50 +206,15 @@ export default function App() {
|
|||||||
</Header>
|
</Header>
|
||||||
<Content style={{ padding: 24 }}>
|
<Content style={{ padding: 24 }}>
|
||||||
<div className="filter-bar">
|
<div className="filter-bar">
|
||||||
{/* In the bar rather than inside the drawer, deliberately. The default
|
<FilterBar
|
||||||
now hides sold pieces, so a customer who never opens the drawer
|
dimensions={STOREFRONT_DIMENSIONS}
|
||||||
would otherwise have no way to know sold items exist — and on a
|
|
||||||
one-of-a-kind catalogue the sold pieces are part of the story. */}
|
|
||||||
<Segmented
|
|
||||||
aria-label="Filter by availability"
|
|
||||||
// The fallback matches the server's: the favorites view defaults to
|
|
||||||
// everything, so the control must not claim Not Sold while sold
|
|
||||||
// favorites are on screen.
|
|
||||||
value={saleStateFromStatuses(
|
|
||||||
filters.status,
|
|
||||||
STOREFRONT_SALE_STATUSES,
|
|
||||||
filters.favoritesOnly ? 'all' : 'not-sold'
|
|
||||||
)}
|
|
||||||
onChange={(value) => {
|
|
||||||
const state = value as SaleState;
|
|
||||||
applyFilters({
|
|
||||||
...filters,
|
|
||||||
// Not Sold is the default, so it is stored as "no preference"
|
|
||||||
// rather than as an explicit list. That keeps it out of the URL
|
|
||||||
// and out of the Filters (N) count, where it would otherwise
|
|
||||||
// show as an active filter nobody chose.
|
|
||||||
status: state === 'not-sold' ? null : STOREFRONT_SALE_STATUSES[state]
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
options={[
|
|
||||||
{ label: 'Not sold', value: 'not-sold' },
|
|
||||||
{ label: 'Sold', value: 'sold' },
|
|
||||||
{ label: 'All', value: 'all' }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
icon={<FilterOutlined />}
|
|
||||||
onClick={() => setDrawerOpen(true)}
|
|
||||||
type={activeCount ? 'primary' : 'default'}
|
|
||||||
>
|
|
||||||
Filters{activeCount ? ` (${activeCount})` : ''}
|
|
||||||
</Button>
|
|
||||||
<ActiveFilterChips
|
|
||||||
categories={options?.categories ?? []}
|
|
||||||
tags={options?.tags ?? []}
|
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onChange={applyFilters}
|
onChange={applyFilters}
|
||||||
onClear={clearFilters}
|
onClear={clearFilters}
|
||||||
|
categories={options?.categories ?? []}
|
||||||
|
tags={options?.tags ?? []}
|
||||||
|
priceRange={options?.priceRange ?? null}
|
||||||
|
resultCount={items.length}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -265,7 +245,7 @@ export default function App() {
|
|||||||
failed={failed}
|
failed={failed}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
items={items}
|
items={items}
|
||||||
filters={filters}
|
filtered={filtered}
|
||||||
needsFavoritesAuth={needsFavoritesAuth}
|
needsFavoritesAuth={needsFavoritesAuth}
|
||||||
onRetry={retry}
|
onRetry={retry}
|
||||||
onSignIn={openAuthModal}
|
onSignIn={openAuthModal}
|
||||||
@@ -278,19 +258,6 @@ export default function App() {
|
|||||||
<Link to="/privacy">Privacy Policy</Link>
|
<Link to="/privacy">Privacy Policy</Link>
|
||||||
</Footer>
|
</Footer>
|
||||||
|
|
||||||
<FilterDrawer
|
|
||||||
open={drawerOpen}
|
|
||||||
onClose={() => setDrawerOpen(false)}
|
|
||||||
categories={options?.categories ?? []}
|
|
||||||
tags={options?.tags ?? []}
|
|
||||||
priceRange={options?.priceRange ?? null}
|
|
||||||
filters={filters}
|
|
||||||
onChange={applyFilters}
|
|
||||||
onClear={clearFilters}
|
|
||||||
resultCount={items.length}
|
|
||||||
showFavorites
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* The same prompt the heart button and Add to Cart use. Signing in
|
{/* 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
|
resolves the gate above, and the filter then applies on its own — the
|
||||||
customer never has to set it a second time. */}
|
customer never has to set it a second time. */}
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
import Tag from 'antd/es/tag';
|
|
||||||
import Button from 'antd/es/button';
|
|
||||||
import type { Category, Tag as ItemTag } from '../api';
|
|
||||||
import { ItemFilters, categoryPath, formatPriceRange, hasActiveFilters, statusLabel } from '../filters';
|
|
||||||
|
|
||||||
type Props = Readonly<{
|
|
||||||
categories: Category[];
|
|
||||||
tags: ItemTag[];
|
|
||||||
filters: ItemFilters;
|
|
||||||
onChange: (filters: ItemFilters) => void;
|
|
||||||
onClear: () => void;
|
|
||||||
// Where status is one of the drawer's controls rather than a preset beside
|
|
||||||
// it (#169), it needs a chip too — otherwise the one filter most likely to
|
|
||||||
// empty a table is the one filter invisible without opening the drawer.
|
|
||||||
showStatus?: boolean;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export default function ActiveFilterChips({
|
|
||||||
categories,
|
|
||||||
tags,
|
|
||||||
filters,
|
|
||||||
onChange,
|
|
||||||
onClear,
|
|
||||||
showStatus = false
|
|
||||||
}: Props) {
|
|
||||||
if (!hasActiveFilters(filters)) return null;
|
|
||||||
|
|
||||||
// `color` only ever set for tags, which are the only filter with one. That
|
|
||||||
// makes colour in this row mean "this is a tag", which is a useful thing for
|
|
||||||
// a row mixing four kinds of filter to say — and nothing depends on it, since
|
|
||||||
// every chip still carries its label.
|
|
||||||
const chips: { key: string; label: string; color?: string; onRemove: () => void }[] = [];
|
|
||||||
|
|
||||||
// Listed first so it matches the drawer's ordering, and because it is the
|
|
||||||
// chip most worth noticing when a customer wonders why the grid looks short.
|
|
||||||
if (filters.favoritesOnly) {
|
|
||||||
chips.push({
|
|
||||||
key: 'favorites',
|
|
||||||
label: 'My favorites',
|
|
||||||
onRemove: () => onChange({ ...filters, favoritesOnly: false })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// One chip per selected category, each removable on its own — removing the
|
|
||||||
// whole set at once is what Clear all is for.
|
|
||||||
for (const categoryId of filters.categoryIds) {
|
|
||||||
const path = categoryPath(categories, categoryId);
|
|
||||||
// Falls back to the raw id while /api/filters is still loading, so the chip
|
|
||||||
// never renders as an empty box.
|
|
||||||
const label = path || `Category ${categoryId}`;
|
|
||||||
chips.push({
|
|
||||||
key: `category-${categoryId}`,
|
|
||||||
// The chip shows the full path for context, since two categories can
|
|
||||||
// share a leaf name under different parents.
|
|
||||||
label,
|
|
||||||
onRemove: () =>
|
|
||||||
onChange({ ...filters, categoryIds: filters.categoryIds.filter((id) => id !== categoryId) })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const tagId of filters.tagIds) {
|
|
||||||
const tag = tags.find((candidate) => candidate.id === tagId);
|
|
||||||
chips.push({
|
|
||||||
key: `tag-${tagId}`,
|
|
||||||
label: tag?.name ?? `Tag ${tagId}`,
|
|
||||||
// The same colour the drawer's control and the product cards show, so a
|
|
||||||
// tag looks like itself wherever it appears. Undefined while
|
|
||||||
// /api/filters is still loading, which is the case the label fallback
|
|
||||||
// above already covers — an uncoloured chip beats a missing one.
|
|
||||||
color: tag?.color,
|
|
||||||
onRemove: () => onChange({ ...filters, tagIds: filters.tagIds.filter((id) => id !== tagId) })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showStatus && filters.status !== null) {
|
|
||||||
for (const status of filters.status) {
|
|
||||||
chips.push({
|
|
||||||
key: `status-${status}`,
|
|
||||||
label: statusLabel(status),
|
|
||||||
onRemove: () => {
|
|
||||||
const rest = (filters.status ?? []).filter((value) => value !== status);
|
|
||||||
// Back to null rather than an empty list: emptying the control means
|
|
||||||
// "no status filter", not "no statuses", which would empty the table.
|
|
||||||
onChange({ ...filters, status: rest.length ? rest : null });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) {
|
|
||||||
chips.push({
|
|
||||||
key: 'price',
|
|
||||||
label: formatPriceRange(filters.minPriceCents, filters.maxPriceCents),
|
|
||||||
onRemove: () => onChange({ ...filters, minPriceCents: null, maxPriceCents: null })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
// Named as a group so the chip row's own "Clear all" stays distinguishable
|
|
||||||
// from the identically-labelled one in the filter drawer.
|
|
||||||
<div className="active-filter-chips" role="group" aria-label="Active filters">
|
|
||||||
{chips.map((chip) => (
|
|
||||||
<Tag
|
|
||||||
key={chip.key}
|
|
||||||
// Undefined for every filter that has no colour of its own, which is
|
|
||||||
// antd's default rendering — the same as before this distinguished
|
|
||||||
// tags. The custom close icon below inherits the tag's text colour,
|
|
||||||
// so a coloured chip gets a matching cross rather than a grey one.
|
|
||||||
color={chip.color}
|
|
||||||
closable
|
|
||||||
onClose={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
chip.onRemove();
|
|
||||||
}}
|
|
||||||
// antd renders the close control as an icon with no text, so name it
|
|
||||||
// for screen readers and for anything driving the page by role.
|
|
||||||
closeIcon={
|
|
||||||
<span role="button" aria-label={`Remove filter ${chip.label.split(' / ').pop()}`}>×</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{chip.label}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
<Button size="small" type="link" onClick={onClear}>Clear all</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
import Drawer from 'antd/es/drawer';
|
|
||||||
import Button from 'antd/es/button';
|
|
||||||
import TreeSelect from 'antd/es/tree-select';
|
|
||||||
import Select from 'antd/es/select';
|
|
||||||
import Tag from 'antd/es/tag';
|
|
||||||
import Slider from 'antd/es/slider';
|
|
||||||
import InputNumber from 'antd/es/input-number';
|
|
||||||
import Empty from 'antd/es/empty';
|
|
||||||
import Switch from 'antd/es/switch';
|
|
||||||
import Grid from 'antd/es/grid';
|
|
||||||
import type { Category, Tag as ItemTag } from '../api';
|
|
||||||
import { ItemFilters, ItemStatus, STATUS_OPTIONS, buildCategoryTree, toCategoryTreeData } from '../filters';
|
|
||||||
|
|
||||||
// One drawer for the storefront and the admin, with the sections that differ
|
|
||||||
// driven by props rather than by a second component that would drift (#169).
|
|
||||||
// What is shared is not just the markup but the phrasing of the rules — that
|
|
||||||
// categories are OR and tags are AND has to read the same on both screens or it
|
|
||||||
// stops being one rule.
|
|
||||||
type Props = Readonly<{
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
categories: Category[];
|
|
||||||
tags: ItemTag[];
|
|
||||||
// Bounds for the price slider, or null on a screen with no catalogue-wide
|
|
||||||
// range to draw one from, where the two number inputs stand alone. A slider
|
|
||||||
// needs real bounds: invented ones would misreport where the prices are.
|
|
||||||
priceRange: { min_cents: number; max_cents: number } | null;
|
|
||||||
filters: ItemFilters;
|
|
||||||
onChange: (filters: ItemFilters) => void;
|
|
||||||
onClear: () => void;
|
|
||||||
resultCount: number;
|
|
||||||
// Storefront only — signing in is what makes favorites mean anything.
|
|
||||||
showFavorites?: boolean;
|
|
||||||
// Admin only. The storefront keeps its three-way preset outside the drawer:
|
|
||||||
// pending is excluded from every public read, so Published and Unpublished
|
|
||||||
// are not distinctions a customer can draw.
|
|
||||||
showStatus?: boolean;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const sectionHeading: React.CSSProperties = {
|
|
||||||
margin: '0 0 8px',
|
|
||||||
fontSize: 12,
|
|
||||||
letterSpacing: '.06em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
opacity: 0.65
|
|
||||||
};
|
|
||||||
|
|
||||||
const centsToDollars = (cents: number | null): number | null => (cents === null ? null : cents / 100);
|
|
||||||
const dollarsToCents = (dollars: number | null): number | null =>
|
|
||||||
dollars === null || Number.isNaN(dollars) ? null : Math.round(dollars * 100);
|
|
||||||
|
|
||||||
export default function FilterDrawer({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
categories,
|
|
||||||
tags,
|
|
||||||
priceRange,
|
|
||||||
filters,
|
|
||||||
onChange,
|
|
||||||
onClear,
|
|
||||||
resultCount,
|
|
||||||
showFavorites = false,
|
|
||||||
showStatus = false
|
|
||||||
}: Props) {
|
|
||||||
const screens = Grid.useBreakpoint();
|
|
||||||
const bounds = priceRange ?? { min_cents: 0, max_cents: 0 };
|
|
||||||
|
|
||||||
function selectCategories(ids: number[]) {
|
|
||||||
onChange({ ...filters, categoryIds: ids });
|
|
||||||
}
|
|
||||||
|
|
||||||
// The selected pills are rendered by the Select, which is handed ids rather
|
|
||||||
// than tags, so the colour has to be looked up rather than carried along.
|
|
||||||
const tagColors = new Map(tags.map((tag) => [tag.id, tag.color]));
|
|
||||||
|
|
||||||
const sliderMax = Math.max(bounds.max_cents, bounds.min_cents + 100);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
title="Filters"
|
|
||||||
placement="right"
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
// Unmounting on close keeps a single copy of controls like "Clear all" in
|
|
||||||
// the document at any time.
|
|
||||||
destroyOnHidden
|
|
||||||
width={screens.md ? 380 : '90%'}
|
|
||||||
footer={
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<Button block onClick={onClear}>Clear all</Button>
|
|
||||||
<Button block type="primary" onClick={onClose}>
|
|
||||||
Show {resultCount} {resultCount === 1 ? 'item' : 'items'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* First because it is the broadest cut, and because a customer who came
|
|
||||||
here for their favorites should not have to scroll past the catalogue
|
|
||||||
controls to find it. Shown to signed-out visitors too: switching it on
|
|
||||||
prompts them to sign in, which is how they learn favorites exist. */}
|
|
||||||
{showFavorites && (
|
|
||||||
<section style={{ marginBottom: 28 }}>
|
|
||||||
<h4 style={sectionHeading}>
|
|
||||||
Favorites
|
|
||||||
</h4>
|
|
||||||
{/* Deliberately not wrapped in a <label>: antd renders the switch as a
|
|
||||||
button, which is labelable, so a wrapping label can forward a click
|
|
||||||
the switch already handled and toggle it twice. The accessible name
|
|
||||||
comes from aria-label instead. */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<Switch
|
|
||||||
checked={filters.favoritesOnly}
|
|
||||||
onChange={(checked) => onChange({ ...filters, favoritesOnly: checked })}
|
|
||||||
aria-label="Only my favorites"
|
|
||||||
/>
|
|
||||||
<span>Only my favorites</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section style={{ marginBottom: 28 }}>
|
|
||||||
<h4 style={sectionHeading}>
|
|
||||||
Categories — any of these
|
|
||||||
</h4>
|
|
||||||
{categories.length ? (
|
|
||||||
// A TreeSelect rather than a Tree: it keeps the hierarchy a customer
|
|
||||||
// browses by while adding search and multi-select, and it lists what
|
|
||||||
// is chosen inside the control instead of leaving the selection to be
|
|
||||||
// read off highlighting. The admin's CategoryTreeSelect is the same
|
|
||||||
// control, so the two screens behave alike.
|
|
||||||
<TreeSelect
|
|
||||||
treeData={toCategoryTreeData(buildCategoryTree(categories))}
|
|
||||||
value={filters.categoryIds}
|
|
||||||
onChange={selectCategories}
|
|
||||||
multiple
|
|
||||||
showSearch
|
|
||||||
// Search the visible label, not the value, which is a numeric id.
|
|
||||||
treeNodeFilterProp="title"
|
|
||||||
treeDefaultExpandAll
|
|
||||||
allowClear
|
|
||||||
placeholder="Any category"
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
aria-label="Filter by category"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No categories yet" />
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section style={{ marginBottom: 28 }}>
|
|
||||||
<h4 style={sectionHeading}>
|
|
||||||
Tags — must have all of these
|
|
||||||
</h4>
|
|
||||||
{tags.length ? (
|
|
||||||
// Was a wall of every tag in the system, which read fine at a dozen
|
|
||||||
// and not at a hundred. A searchable multi-select scales with the
|
|
||||||
// taxonomy and, like the category control above it, states its
|
|
||||||
// selection inside the control instead of in chip colouring.
|
|
||||||
//
|
|
||||||
// The colours survive as the selected pills, since that is the only
|
|
||||||
// place a tag's colour was ever load-bearing.
|
|
||||||
<Select
|
|
||||||
mode="multiple"
|
|
||||||
showSearch
|
|
||||||
optionFilterProp="label"
|
|
||||||
allowClear
|
|
||||||
placeholder="Any tags"
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
aria-label="Filter by tags"
|
|
||||||
value={filters.tagIds}
|
|
||||||
onChange={(tagIds: number[]) => onChange({ ...filters, tagIds })}
|
|
||||||
options={tags.map((tag) => ({ value: tag.id, label: tag.name }))}
|
|
||||||
tagRender={({ value, label, closable, onClose }) => (
|
|
||||||
<Tag
|
|
||||||
color={tagColors.get(Number(value))}
|
|
||||||
closable={closable}
|
|
||||||
onClose={onClose}
|
|
||||||
// antd's default, which the custom renderer replaces: without
|
|
||||||
// it the pill swallows the mousedown and reopens the list.
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
}}
|
|
||||||
style={{ marginInlineEnd: 4 }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No tags yet" />
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section style={showStatus ? { marginBottom: 28 } : undefined}>
|
|
||||||
<h4 style={sectionHeading}>
|
|
||||||
Price
|
|
||||||
</h4>
|
|
||||||
{priceRange && (
|
|
||||||
<Slider
|
|
||||||
range
|
|
||||||
min={bounds.min_cents}
|
|
||||||
max={sliderMax}
|
|
||||||
step={100}
|
|
||||||
value={[filters.minPriceCents ?? bounds.min_cents, filters.maxPriceCents ?? sliderMax]}
|
|
||||||
tooltip={{ formatter: (value) => `$${((value ?? 0) / 100).toFixed(0)}` }}
|
|
||||||
onChange={([min, max]) =>
|
|
||||||
// antd types the slider's value as number[], so destructuring gives
|
|
||||||
// `number | undefined`. A range slider always emits both ends; the
|
|
||||||
// fallbacks are the bounds it was given rather than nulls, which
|
|
||||||
// would read as "no filter" and widen the results.
|
|
||||||
onChange({
|
|
||||||
...filters,
|
|
||||||
minPriceCents: min ?? bounds.min_cents,
|
|
||||||
maxPriceCents: max ?? sliderMax
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
|
|
||||||
<InputNumber
|
|
||||||
aria-label="Minimum price"
|
|
||||||
prefix="$"
|
|
||||||
min={0}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
value={centsToDollars(filters.minPriceCents)}
|
|
||||||
onChange={(value) => onChange({ ...filters, minPriceCents: dollarsToCents(value) })}
|
|
||||||
/>
|
|
||||||
<span style={{ opacity: 0.6 }}>to</span>
|
|
||||||
<InputNumber
|
|
||||||
aria-label="Maximum price"
|
|
||||||
prefix="$"
|
|
||||||
min={0}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
value={centsToDollars(filters.maxPriceCents)}
|
|
||||||
onChange={(value) => onChange({ ...filters, maxPriceCents: dollarsToCents(value) })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{showStatus && (
|
|
||||||
<section>
|
|
||||||
<h4 style={sectionHeading}>
|
|
||||||
Status — any of these
|
|
||||||
</h4>
|
|
||||||
{/* The status dimension itself rather than presets over it, which
|
|
||||||
#105's Sold / Not sold / All control was. Presets could not express
|
|
||||||
Published or Unpublished, could not isolate Reserved, and would
|
|
||||||
have grown a new button for every new question. Selecting statuses
|
|
||||||
answers all of them: Unpublished is Pending, Published is the other
|
|
||||||
three, and Not sold is everything except Sold.
|
|
||||||
|
|
||||||
A second control for publication would have read more naturally and
|
|
||||||
reintroduced what #105 avoided — Sold and Unpublished is an
|
|
||||||
impossible pair, since a sold item is necessarily published. One
|
|
||||||
dimension cannot contradict itself. See #132. */}
|
|
||||||
<Select
|
|
||||||
allowClear
|
|
||||||
mode="multiple"
|
|
||||||
showSearch
|
|
||||||
optionFilterProp="label"
|
|
||||||
placeholder="Any status"
|
|
||||||
aria-label="Filter by status"
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
value={filters.status ?? []}
|
|
||||||
onChange={(value: ItemStatus[]) =>
|
|
||||||
// Empty means no filter, not "no statuses". A multi-select cleared
|
|
||||||
// back to nothing should show everything rather than an empty table.
|
|
||||||
onChange({ ...filters, status: value.length ? value : null })
|
|
||||||
}
|
|
||||||
options={STATUS_OPTIONS}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -150,13 +150,6 @@ export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// One count for the "Filters (N)" button. A price range counts once however
|
|
||||||
// many ends are set, since it reads as a single filter to the user.
|
|
||||||
//
|
|
||||||
// Status is deliberately not counted. It has its own always-visible control
|
|
||||||
// beside this button rather than living in the drawer, so counting it would put
|
|
||||||
// a number on a button whose drawer shows nothing set — and the control already
|
|
||||||
// displays its own position.
|
|
||||||
// Named individually rather than grouped, because grouping is what the preset
|
// Named individually rather than grouped, because grouping is what the preset
|
||||||
// this replaced did. Pending is listed first: "what is waiting to be published"
|
// this replaced did. Pending is listed first: "what is waiting to be published"
|
||||||
// is the question that prompted #132.
|
// is the question that prompted #132.
|
||||||
@@ -175,24 +168,6 @@ export function statusLabel(status: ItemStatus): string {
|
|||||||
return STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status;
|
return STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activeFilterCount(filters: ItemFilters): number {
|
|
||||||
let count = 0;
|
|
||||||
count += filters.categoryIds.length;
|
|
||||||
count += filters.tagIds.length;
|
|
||||||
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
|
|
||||||
if (filters.favoritesOnly) count++;
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broader than the count above, and intentionally so: this decides whether an
|
|
||||||
// empty result reads as "no items match these filters" with a way out, or as an
|
|
||||||
// empty shop. A status filter that matched nothing is exactly the case where
|
|
||||||
// that distinction matters, so it counts here even though it is not in the
|
|
||||||
// drawer's tally.
|
|
||||||
export function hasActiveFilters(filters: ItemFilters): boolean {
|
|
||||||
return activeFilterCount(filters) > 0 || filters.status !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CategoryNode extends Category {
|
export interface CategoryNode extends Category {
|
||||||
children: CategoryNode[];
|
children: CategoryNode[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user