feat(admin): filter inventory through the same flyout the storefront uses (#169)
The two screens asked the same questions through different UI. The storefront had searchable multi-selects in a flyout; the admin still had an always-visible row of controls with a single-select category that held a list of at most one, which is what #139 left behind so the shared filter type would not have to change shape twice. The drawer is now one component with the sections that differ driven by props rather than a second copy that would drift. Favorites is storefront-only. Status is admin-only, since pending is excluded from every public read and Published or Unpublished are not distinctions a customer can draw — the storefront keeps its three-way preset outside the drawer. The price slider needs real catalogue-wide bounds to be honest about where the prices are, and the admin has none, so there it is the two number inputs alone. What is shared is not only the markup but the phrasing: that categories are OR and tags are AND has to read the same on both screens or it stops being one rule. This reverses a decision `InventoryFilters.tsx` argued for in a comment — that hiding controls above a data table costs more than the space it saves, and that a drawer overlays the very rows being filtered. Both are true and both are traded for consistency between the panels. The active-filter chips are what makes the trade bearable: the current filter stays readable beside the button without opening anything, which is the part the always-visible row was really protecting. Status gets chips too, since it is now behind the button and is the filter most likely to empty a table. `STATUS_OPTIONS` moves beside the filter type, because the drawer and the chips both need to turn a status into a label and a second copy is a second place for a new status to be forgotten. The admin page object opens the flyout, acts, and closes it again — closing matters, because the drawer overlays the table every assertion in those specs is about. Closes #169
This commit is contained in:
@@ -1,21 +1,30 @@
|
||||
import Tag from 'antd/es/tag';
|
||||
import Button from 'antd/es/button';
|
||||
import type { FilterOptions } from '../api';
|
||||
import { ItemFilters, categoryPath, formatPriceRange, hasActiveFilters } from '../filters';
|
||||
import type { Category, Tag as ItemTag } from '../api';
|
||||
import { ItemFilters, categoryPath, formatPriceRange, hasActiveFilters, statusLabel } from '../filters';
|
||||
|
||||
type Props = Readonly<{
|
||||
options: FilterOptions | null;
|
||||
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({ options, filters, onChange, onClear }: Props) {
|
||||
export default function ActiveFilterChips({
|
||||
categories,
|
||||
tags,
|
||||
filters,
|
||||
onChange,
|
||||
onClear,
|
||||
showStatus = false
|
||||
}: Props) {
|
||||
if (!hasActiveFilters(filters)) return null;
|
||||
|
||||
const categories = options?.categories ?? [];
|
||||
const tags = options?.tags ?? [];
|
||||
|
||||
const chips: { key: string; label: string; onRemove: () => void }[] = [];
|
||||
|
||||
// Listed first so it matches the drawer's ordering, and because it is the
|
||||
@@ -54,6 +63,21 @@ export default function ActiveFilterChips({ options, filters, onChange, onClear
|
||||
});
|
||||
}
|
||||
|
||||
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',
|
||||
|
||||
@@ -8,17 +8,33 @@ 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 { FilterOptions } from '../api';
|
||||
import { ItemFilters, buildCategoryTree, CategoryNode } from '../filters';
|
||||
import type { Category, Tag as ItemTag } from '../api';
|
||||
import { ItemFilters, ItemStatus, STATUS_OPTIONS, buildCategoryTree, CategoryNode } 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;
|
||||
options: FilterOptions | null;
|
||||
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;
|
||||
}>;
|
||||
|
||||
// `value` rather than `key`: this fed an antd `Tree`, which identifies nodes by
|
||||
@@ -38,6 +54,14 @@ function toTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
|
||||
}));
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -45,16 +69,18 @@ const dollarsToCents = (dollars: number | null): number | null =>
|
||||
export default function FilterDrawer({
|
||||
open,
|
||||
onClose,
|
||||
options,
|
||||
categories,
|
||||
tags,
|
||||
priceRange,
|
||||
filters,
|
||||
onChange,
|
||||
onClear,
|
||||
resultCount
|
||||
resultCount,
|
||||
showFavorites = false,
|
||||
showStatus = false
|
||||
}: Props) {
|
||||
const screens = Grid.useBreakpoint();
|
||||
const categories = options?.categories ?? [];
|
||||
const tags = options?.tags ?? [];
|
||||
const bounds = options?.priceRange ?? { min_cents: 0, max_cents: 0 };
|
||||
const bounds = priceRange ?? { min_cents: 0, max_cents: 0 };
|
||||
|
||||
function selectCategories(ids: number[]) {
|
||||
onChange({ ...filters, categoryIds: ids });
|
||||
@@ -89,8 +115,9 @@ export default function FilterDrawer({
|
||||
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={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||
<h4 style={sectionHeading}>
|
||||
Favorites
|
||||
</h4>
|
||||
{/* Deliberately not wrapped in a <label>: antd renders the switch as a
|
||||
@@ -106,9 +133,10 @@ export default function FilterDrawer({
|
||||
<span>Only my favorites</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section style={{ marginBottom: 28 }}>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||
<h4 style={sectionHeading}>
|
||||
Categories — any of these
|
||||
</h4>
|
||||
{categories.length ? (
|
||||
@@ -137,7 +165,7 @@ export default function FilterDrawer({
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: 28 }}>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||
<h4 style={sectionHeading}>
|
||||
Tags — must have all of these
|
||||
</h4>
|
||||
{tags.length ? (
|
||||
@@ -181,10 +209,11 @@ export default function FilterDrawer({
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||
<section style={showStatus ? { marginBottom: 28 } : undefined}>
|
||||
<h4 style={sectionHeading}>
|
||||
Price
|
||||
</h4>
|
||||
{priceRange && (
|
||||
<Slider
|
||||
range
|
||||
min={bounds.min_cents}
|
||||
@@ -204,6 +233,7 @@ export default function FilterDrawer({
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
|
||||
<InputNumber
|
||||
aria-label="Minimum price"
|
||||
@@ -224,6 +254,41 @@ export default function FilterDrawer({
|
||||
/>
|
||||
</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