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:
@@ -275,6 +275,7 @@ function Inventory() {
|
||||
filters={filters}
|
||||
onChange={applyFilters}
|
||||
onClear={clearFilters}
|
||||
resultCount={items.length}
|
||||
/>
|
||||
|
||||
<Table rowKey="id" dataSource={items} columns={columns} scroll={{ x: true }} />
|
||||
|
||||
@@ -1,40 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import TreeSelect from 'antd/es/tree-select';
|
||||
import Select from 'antd/es/select';
|
||||
import InputNumber from 'antd/es/input-number';
|
||||
import { useState } from 'react';
|
||||
import Button from 'antd/es/button';
|
||||
import { FilterOutlined } from '@ant-design/icons';
|
||||
import type { Category, Tag } from '../api';
|
||||
import {
|
||||
ItemFilters,
|
||||
ItemStatus,
|
||||
buildCategoryTree,
|
||||
CategoryNode,
|
||||
hasActiveFilters
|
||||
} from '../filters';
|
||||
|
||||
// Named individually rather than grouped, because grouping is what the preset
|
||||
// this replaces did. Pending is listed first: "what is waiting to be published"
|
||||
// is the question that prompted #132.
|
||||
const STATUS_OPTIONS: { value: ItemStatus; label: string }[] = [
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'available', label: 'Available' },
|
||||
{ value: 'reserved', label: 'Reserved' },
|
||||
{ value: 'sold', label: 'Sold' }
|
||||
];
|
||||
|
||||
interface CategoryTreeOption {
|
||||
value: number;
|
||||
title: string;
|
||||
children?: CategoryTreeOption[];
|
||||
}
|
||||
|
||||
function toTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
|
||||
return nodes.map((node) => ({
|
||||
value: node.id,
|
||||
title: node.name,
|
||||
children: node.children.length ? toTreeData(node.children) : undefined
|
||||
}));
|
||||
}
|
||||
import { ItemFilters, activeFilterCount } from '../filters';
|
||||
import FilterDrawer from '../components/FilterDrawer';
|
||||
import ActiveFilterChips from '../components/ActiveFilterChips';
|
||||
|
||||
type Props = Readonly<{
|
||||
categories: Category[];
|
||||
@@ -42,103 +12,67 @@ type Props = Readonly<{
|
||||
filters: ItemFilters;
|
||||
onChange: (filters: ItemFilters) => void;
|
||||
onClear: () => void;
|
||||
resultCount: number;
|
||||
}>;
|
||||
|
||||
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);
|
||||
// The same flyout the storefront uses, rather than the row of controls this
|
||||
// used to be (#169).
|
||||
//
|
||||
// That row was deliberate — it carried a comment arguing that hiding the
|
||||
// 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 the two screens asking the same questions through the same UI.
|
||||
// The chips are what makes the trade bearable: the active filter stays readable
|
||||
// beside the button without opening anything, which is what the always-visible
|
||||
// row was really protecting.
|
||||
export default function InventoryFilters({
|
||||
categories,
|
||||
tags,
|
||||
filters,
|
||||
onChange,
|
||||
onClear,
|
||||
resultCount
|
||||
}: Props) {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
// An always-visible row rather than the storefront's drawer: this sits above a
|
||||
// data table, where hiding the controls behind a click costs more than the
|
||||
// space it saves, and a drawer would overlay the very rows being filtered.
|
||||
export default function InventoryFilters({ categories, tags, filters, onChange, onClear }: Props) {
|
||||
const treeData = useMemo(() => toTreeData(buildCategoryTree(categories)), [categories]);
|
||||
// Status lives in the drawer here, unlike on the storefront, so it belongs in
|
||||
// the button's tally — activeFilterCount leaves it out precisely because the
|
||||
// storefront filters status outside the drawer.
|
||||
const activeCount = activeFilterCount(filters) + (filters.status === null ? 0 : 1);
|
||||
|
||||
return (
|
||||
<div className="inventory-filters">
|
||||
<TreeSelect
|
||||
allowClear
|
||||
showSearch
|
||||
treeNodeFilterProp="title"
|
||||
listHeight={256}
|
||||
placeholder="Any category"
|
||||
aria-label="Filter by category"
|
||||
style={{ minWidth: 200 }}
|
||||
treeData={treeData}
|
||||
// The filter shape went multi-valued for the storefront (#139). This
|
||||
// control stays single-select — the admin asks "what is in this
|
||||
// category", not "in any of these" — so it reads and writes a list of
|
||||
// at most one rather than growing a second shape.
|
||||
value={filters.categoryIds[0] ?? undefined}
|
||||
// Annotated nullable because allowClear hands back undefined, which
|
||||
// the control's own onChange type does not admit.
|
||||
onChange={(value: number | undefined) =>
|
||||
onChange({ ...filters, categoryIds: value === undefined ? [] : [value] })
|
||||
}
|
||||
<Button
|
||||
icon={<FilterOutlined />}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
type={activeCount ? 'primary' : 'default'}
|
||||
>
|
||||
Filters{activeCount ? ` (${activeCount})` : ''}
|
||||
</Button>
|
||||
|
||||
<ActiveFilterChips
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
filters={filters}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
showStatus
|
||||
/>
|
||||
|
||||
<Select
|
||||
allowClear
|
||||
mode="multiple"
|
||||
placeholder="Any tags"
|
||||
aria-label="Filter by tags"
|
||||
style={{ minWidth: 200 }}
|
||||
value={filters.tagIds}
|
||||
onChange={(value: number[]) => onChange({ ...filters, tagIds: value })}
|
||||
options={tags.map((tag) => ({ value: tag.id, label: tag.name }))}
|
||||
<FilterDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
// No slider: the admin has no catalogue-wide price range to bound one
|
||||
// with, and inventing bounds would misreport where the prices are.
|
||||
priceRange={null}
|
||||
filters={filters}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
resultCount={resultCount}
|
||||
showStatus
|
||||
/>
|
||||
|
||||
<InputNumber
|
||||
aria-label="Minimum price"
|
||||
prefix="$"
|
||||
min={0}
|
||||
placeholder="Min"
|
||||
style={{ width: 110 }}
|
||||
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}
|
||||
placeholder="Max"
|
||||
style={{ width: 110 }}
|
||||
value={centsToDollars(filters.maxPriceCents)}
|
||||
onChange={(value) => onChange({ ...filters, maxPriceCents: dollarsToCents(value) })}
|
||||
/>
|
||||
|
||||
{/* 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.
|
||||
|
||||
The storefront keeps the three-way preset: pending is excluded from
|
||||
every public read, so Published and Unpublished are not distinctions a
|
||||
customer can draw. */}
|
||||
<Select
|
||||
allowClear
|
||||
mode="multiple"
|
||||
placeholder="Any status"
|
||||
aria-label="Filter by status"
|
||||
style={{ minWidth: 220 }}
|
||||
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}
|
||||
/>
|
||||
|
||||
{hasActiveFilters(filters) && <Button onClick={onClear}>Clear filters</Button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user