diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f87ddda..cad4bdb 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -230,7 +230,8 @@ export default function App() { Filters{activeCount ? ` (${activeCount})` : ''} setDrawerOpen(false)} - options={options} + 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 diff --git a/frontend/src/admin/Admin.tsx b/frontend/src/admin/Admin.tsx index 0e95270..7bcb6b9 100755 --- a/frontend/src/admin/Admin.tsx +++ b/frontend/src/admin/Admin.tsx @@ -275,6 +275,7 @@ function Inventory() { filters={filters} onChange={applyFilters} onClear={clearFilters} + resultCount={items.length} /> diff --git a/frontend/src/admin/InventoryFilters.tsx b/frontend/src/admin/InventoryFilters.tsx index 44f898a..36f83d7 100644 --- a/frontend/src/admin/InventoryFilters.tsx +++ b/frontend/src/admin/InventoryFilters.tsx @@ -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 (
- - onChange({ ...filters, categoryIds: value === undefined ? [] : [value] }) - } + + + - - // 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) && }
); } diff --git a/frontend/src/components/ActiveFilterChips.tsx b/frontend/src/components/ActiveFilterChips.tsx index 6ddbd71..32a5afe 100644 --- a/frontend/src/components/ActiveFilterChips.tsx +++ b/frontend/src/components/ActiveFilterChips.tsx @@ -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', diff --git a/frontend/src/components/FilterDrawer.tsx b/frontend/src/components/FilterDrawer.tsx index 8ffff8c..efb7b03 100644 --- a/frontend/src/components/FilterDrawer.tsx +++ b/frontend/src/components/FilterDrawer.tsx @@ -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 && (
-

+

Favorites

{/* Deliberately not wrapped in a
+ )}
-

+

Categories — any of these

{categories.length ? ( @@ -137,7 +165,7 @@ export default function FilterDrawer({
-

+

Tags — must have all of these

{tags.length ? ( @@ -181,10 +209,11 @@ export default function FilterDrawer({ )}
-
-

+
+

Price

+ {priceRange && ( + )}
+ + {showStatus && ( +
+

+ Status — any of these +

+ {/* 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. */} +