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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user