Feature/169 admin filter flyout #171
+44
-11
@@ -8,7 +8,14 @@ export type { ItemStatus };
|
|||||||
export class FilterError extends Error {}
|
export class FilterError extends Error {}
|
||||||
|
|
||||||
export interface ItemFilters {
|
export interface ItemFilters {
|
||||||
categoryId: number | null;
|
// Several categories, combined as OR — a customer picking Furniture and
|
||||||
|
// Decor wants both, not the empty intersection. Deliberately the opposite of
|
||||||
|
// how tagIds combine below, which is AND; the two controls say so in the UI
|
||||||
|
// rather than leaving it to be discovered.
|
||||||
|
//
|
||||||
|
// Each selected id still expands to its descendants, so choosing two parents
|
||||||
|
// means "anything filed under either of them".
|
||||||
|
categoryIds: number[];
|
||||||
tagIds: number[];
|
tagIds: number[];
|
||||||
minPriceCents: number | null;
|
minPriceCents: number | null;
|
||||||
maxPriceCents: number | null;
|
maxPriceCents: number | null;
|
||||||
@@ -110,12 +117,33 @@ function parsePrice(value: unknown, name: string): number | null {
|
|||||||
return parseNonNegativeInteger(raw, name);
|
return parseNonNegativeInteger(raw, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCategoryId(value: unknown): number | null {
|
/**
|
||||||
|
* Comma-separated, the same shape `tags` and `status` already use.
|
||||||
|
*
|
||||||
|
* The parameter keeps its singular name so that every `?category=1` link,
|
||||||
|
* bookmark and shared URL written before this went multi-valued still parses —
|
||||||
|
* as a list of one, needing no alias and leaving no way to give the filter
|
||||||
|
* twice with different meanings.
|
||||||
|
*/
|
||||||
|
function parseCategoryIds(value: unknown): number[] {
|
||||||
const raw = singleValue(value, 'category');
|
const raw = singleValue(value, 'category');
|
||||||
if (raw === null || raw === '') {
|
const categoryIds: number[] = [];
|
||||||
return null;
|
if (!raw) {
|
||||||
|
return categoryIds;
|
||||||
}
|
}
|
||||||
return parseId(raw, 'category');
|
for (const part of raw.split(',')) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (trimmed === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const id = parseId(trimmed, 'category');
|
||||||
|
// Duplicates are harmless to the OR below, but they would show twice in
|
||||||
|
// any caller that renders the parsed filter back.
|
||||||
|
if (!categoryIds.includes(id)) {
|
||||||
|
categoryIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return categoryIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTagIds(value: unknown): number[] {
|
function parseTagIds(value: unknown): number[] {
|
||||||
@@ -193,7 +221,7 @@ function parseFavoritesOnly(value: unknown): boolean {
|
|||||||
// deliberate and observable: a query wrong in two ways reports the first
|
// deliberate and observable: a query wrong in two ways reports the first
|
||||||
// field, so moving these lines around changes which error a caller sees.
|
// field, so moving these lines around changes which error a caller sees.
|
||||||
export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
|
export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
|
||||||
const categoryId = parseCategoryId(query.category);
|
const categoryIds = parseCategoryIds(query.category);
|
||||||
const tagIds = parseTagIds(query.tags);
|
const tagIds = parseTagIds(query.tags);
|
||||||
|
|
||||||
const minPriceCents = parsePrice(query.min_price, 'min_price');
|
const minPriceCents = parsePrice(query.min_price, 'min_price');
|
||||||
@@ -205,7 +233,7 @@ export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
|
|||||||
const status = parseStatus(query.status);
|
const status = parseStatus(query.status);
|
||||||
const favoritesOnly = parseFavoritesOnly(query.favorites);
|
const favoritesOnly = parseFavoritesOnly(query.favorites);
|
||||||
|
|
||||||
return { categoryId, tagIds, minPriceCents, maxPriceCents, status, favoritesOnly };
|
return { categoryIds, tagIds, minPriceCents, maxPriceCents, status, favoritesOnly };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns WHERE fragments plus their parameters, with placeholders numbered
|
// Returns WHERE fragments plus their parameters, with placeholders numbered
|
||||||
@@ -226,15 +254,20 @@ export function buildItemFilterSql(
|
|||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
let next = startIndex;
|
let next = startIndex;
|
||||||
|
|
||||||
if (filters.categoryId !== null) {
|
if (filters.categoryIds.length) {
|
||||||
params.push(filters.categoryId);
|
params.push(filters.categoryIds);
|
||||||
// Selecting a category means "and everything filed beneath it", so walk the
|
// Selecting a category means "and everything filed beneath it", so walk the
|
||||||
// tree down from the chosen node. A recursive CTE keeps the tree
|
// tree down from each chosen node. A recursive CTE keeps the tree
|
||||||
// un-denormalized: reparenting stays a single UPDATE with no stored paths
|
// un-denormalized: reparenting stays a single UPDATE with no stored paths
|
||||||
// to rewrite.
|
// to rewrite.
|
||||||
|
//
|
||||||
|
// Seeded with `= ANY(...)` rather than one id, so every selected root is
|
||||||
|
// walked in the same recursion. That also gives the OR for free: the union
|
||||||
|
// of the subtrees is exactly "filed under any of these", and an item filed
|
||||||
|
// under two selected branches appears once because IN is a set test.
|
||||||
clauses.push(`i.category_id IN (
|
clauses.push(`i.category_id IN (
|
||||||
WITH RECURSIVE subtree AS (
|
WITH RECURSIVE subtree AS (
|
||||||
SELECT id FROM categories WHERE id = $${next}
|
SELECT id FROM categories WHERE id = ANY($${next}::int[])
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id
|
SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -371,6 +371,34 @@ describe('GET /api/items filtering', () => {
|
|||||||
expect(res.body.map((i: { name: string }) => i.name).sort()).toEqual(['Deep', 'Mid', 'Top']);
|
expect(res.body.map((i: { name: string }) => i.name).sort()).toEqual(['Deep', 'Mid', 'Top']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('combines several categories as any-of rather than all-of', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
const tables = await createCategory('Tables', furniture);
|
||||||
|
const decor = await createCategory('Decor');
|
||||||
|
const art = await createCategory('Art');
|
||||||
|
|
||||||
|
await createItem('Nested', 1000, tables);
|
||||||
|
await createItem('Elsewhere', 1000, decor);
|
||||||
|
await createItem('Unrelated', 1000, art);
|
||||||
|
|
||||||
|
// Two branches sharing no items, so an AND would answer with nothing at
|
||||||
|
// all — which is the failure this exists to catch.
|
||||||
|
const res = await request(app).get(`/api/items?category=${furniture},${decor}`);
|
||||||
|
expect(res.body.map((i: { name: string }) => i.name).sort()).toEqual(['Elsewhere', 'Nested']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists an item once when its category sits under two selected branches', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
const tables = await createCategory('Tables', furniture);
|
||||||
|
await createItem('Nested', 1000, tables);
|
||||||
|
|
||||||
|
// The subtrees overlap: Tables is walked from both seeds, so the recursion
|
||||||
|
// yields its id twice. Selecting on IN makes that a set test rather than a
|
||||||
|
// join, so the item still appears once.
|
||||||
|
const res = await request(app).get(`/api/items?category=${furniture},${tables}`);
|
||||||
|
expect(res.body.map((i: { name: string }) => i.name)).toEqual(['Nested']);
|
||||||
|
});
|
||||||
|
|
||||||
it('excludes uncategorized items from a category filter', async () => {
|
it('excludes uncategorized items from a category filter', async () => {
|
||||||
const furniture = await createCategory('Furniture');
|
const furniture = await createCategory('Furniture');
|
||||||
await createItem('Filed', 1000, furniture);
|
await createItem('Filed', 1000, furniture);
|
||||||
@@ -444,6 +472,16 @@ describe('GET /api/items filtering', () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects a category list with one unreadable entry rather than honouring the rest', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
await createItem('A', 1000, furniture);
|
||||||
|
|
||||||
|
// Filtering on the readable half would answer a narrower question than the
|
||||||
|
// one asked, and look indistinguishable from a filter that worked.
|
||||||
|
const res = await request(app).get(`/api/items?category=${furniture},furniture`);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects an inverted price range', async () => {
|
it('rejects an inverted price range', async () => {
|
||||||
const res = await request(app).get('/api/items?min_price=5000&max_price=1000');
|
const res = await request(app).get('/api/items?min_price=5000&max_price=1000');
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { parseItemFilters, FilterError, buildItemFilterSql } from '../../src/ite
|
|||||||
describe('parseItemFilters', () => {
|
describe('parseItemFilters', () => {
|
||||||
it('returns empty filters for an empty query', () => {
|
it('returns empty filters for an empty query', () => {
|
||||||
expect(parseItemFilters({})).toEqual({
|
expect(parseItemFilters({})).toEqual({
|
||||||
categoryId: null,
|
categoryIds: [],
|
||||||
tagIds: [],
|
tagIds: [],
|
||||||
minPriceCents: null,
|
minPriceCents: null,
|
||||||
maxPriceCents: null,
|
maxPriceCents: null,
|
||||||
@@ -12,8 +12,30 @@ describe('parseItemFilters', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses a category id', () => {
|
// The parameter stayed singular when it went multi-valued (#139), so every
|
||||||
expect(parseItemFilters({ category: '7' }).categoryId).toBe(7);
|
// link written before that still parses — as a list of one.
|
||||||
|
it('parses a single category id, as older links still send it', () => {
|
||||||
|
expect(parseItemFilters({ category: '7' }).categoryIds).toEqual([7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a comma-separated category list', () => {
|
||||||
|
expect(parseItemFilters({ category: '4,9,2' }).categoryIds).toEqual([4, 9, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses duplicate category ids', () => {
|
||||||
|
expect(parseItemFilters({ category: '3,3,8' }).categoryIds).toEqual([3, 8]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an empty category list as no category filter', () => {
|
||||||
|
expect(parseItemFilters({ category: '' }).categoryIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Decision 9: a malformed filter shows itself rather than quietly returning
|
||||||
|
// the whole catalogue, and going multi-valued must not weaken that.
|
||||||
|
it('refuses a category list containing anything unreadable', () => {
|
||||||
|
expect(() => parseItemFilters({ category: '4,nope' })).toThrow(FilterError);
|
||||||
|
expect(() => parseItemFilters({ category: '0' })).toThrow(FilterError);
|
||||||
|
expect(() => parseItemFilters({ category: '-1,2' })).toThrow(FilterError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses a comma-separated tag list', () => {
|
it('parses a comma-separated tag list', () => {
|
||||||
@@ -158,7 +180,19 @@ describe('buildItemFilterSql', () => {
|
|||||||
it('matches a category and all of its descendants', () => {
|
it('matches a category and all of its descendants', () => {
|
||||||
const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 1, null);
|
const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 1, null);
|
||||||
expect(built.clauses.join(' ')).toContain('RECURSIVE');
|
expect(built.clauses.join(' ')).toContain('RECURSIVE');
|
||||||
expect(built.params).toEqual([4]);
|
// One array parameter rather than one id: the CTE is seeded with ANY so
|
||||||
|
// several selected roots are walked in the same recursion.
|
||||||
|
expect(built.params).toEqual([[4]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds the descendant walk with every selected category', () => {
|
||||||
|
const built = buildItemFilterSql(parseItemFilters({ category: '4,9' }), 1, null);
|
||||||
|
const sql = built.clauses.join(' ');
|
||||||
|
expect(sql).toContain('RECURSIVE');
|
||||||
|
// ANY over the seeds is what makes several categories combine as OR: the
|
||||||
|
// result is the union of their subtrees.
|
||||||
|
expect(sql).toContain('= ANY($1::int[])');
|
||||||
|
expect(built.params).toEqual([[4, 9]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires every listed tag rather than any of them', () => {
|
it('requires every listed tag rather than any of them', () => {
|
||||||
@@ -215,7 +249,7 @@ describe('buildItemFilterSql', () => {
|
|||||||
1,
|
1,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
expect(built.params).toEqual([4, 100, 900]);
|
expect(built.params).toEqual([[4], 100, 900]);
|
||||||
const sql = built.clauses.join(' ');
|
const sql = built.clauses.join(' ');
|
||||||
expect(sql).toContain('$1');
|
expect(sql).toContain('$1');
|
||||||
expect(sql).toContain('$2');
|
expect(sql).toContain('$2');
|
||||||
|
|||||||
@@ -230,7 +230,8 @@ export default function App() {
|
|||||||
Filters{activeCount ? ` (${activeCount})` : ''}
|
Filters{activeCount ? ` (${activeCount})` : ''}
|
||||||
</Button>
|
</Button>
|
||||||
<ActiveFilterChips
|
<ActiveFilterChips
|
||||||
options={options}
|
categories={options?.categories ?? []}
|
||||||
|
tags={options?.tags ?? []}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onChange={applyFilters}
|
onChange={applyFilters}
|
||||||
onClear={clearFilters}
|
onClear={clearFilters}
|
||||||
@@ -280,11 +281,14 @@ export default function App() {
|
|||||||
<FilterDrawer
|
<FilterDrawer
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
options={options}
|
categories={options?.categories ?? []}
|
||||||
|
tags={options?.tags ?? []}
|
||||||
|
priceRange={options?.priceRange ?? null}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onChange={applyFilters}
|
onChange={applyFilters}
|
||||||
onClear={clearFilters}
|
onClear={clearFilters}
|
||||||
resultCount={items.length}
|
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
|
||||||
|
|||||||
@@ -275,6 +275,7 @@ function Inventory() {
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
onChange={applyFilters}
|
onChange={applyFilters}
|
||||||
onClear={clearFilters}
|
onClear={clearFilters}
|
||||||
|
resultCount={items.length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Table rowKey="id" dataSource={items} columns={columns} scroll={{ x: true }} />
|
<Table rowKey="id" dataSource={items} columns={columns} scroll={{ x: true }} />
|
||||||
|
|||||||
@@ -1,40 +1,10 @@
|
|||||||
import { useMemo } from 'react';
|
import { useState } from 'react';
|
||||||
import TreeSelect from 'antd/es/tree-select';
|
|
||||||
import Select from 'antd/es/select';
|
|
||||||
import InputNumber from 'antd/es/input-number';
|
|
||||||
import Button from 'antd/es/button';
|
import Button from 'antd/es/button';
|
||||||
|
import { FilterOutlined } from '@ant-design/icons';
|
||||||
import type { Category, Tag } from '../api';
|
import type { Category, Tag } from '../api';
|
||||||
import {
|
import { ItemFilters, activeFilterCount } from '../filters';
|
||||||
ItemFilters,
|
import FilterDrawer from '../components/FilterDrawer';
|
||||||
ItemStatus,
|
import ActiveFilterChips from '../components/ActiveFilterChips';
|
||||||
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
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = Readonly<{
|
type Props = Readonly<{
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
@@ -42,95 +12,67 @@ type Props = Readonly<{
|
|||||||
filters: ItemFilters;
|
filters: ItemFilters;
|
||||||
onChange: (filters: ItemFilters) => void;
|
onChange: (filters: ItemFilters) => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
|
resultCount: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const centsToDollars = (cents: number | null): number | null => (cents === null ? null : cents / 100);
|
// The same flyout the storefront uses, rather than the row of controls this
|
||||||
const dollarsToCents = (dollars: number | null): number | null =>
|
// used to be (#169).
|
||||||
dollars === null || Number.isNaN(dollars) ? null : Math.round(dollars * 100);
|
//
|
||||||
|
// 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
|
// Status lives in the drawer here, unlike on the storefront, so it belongs in
|
||||||
// data table, where hiding the controls behind a click costs more than the
|
// the button's tally — activeFilterCount leaves it out precisely because the
|
||||||
// space it saves, and a drawer would overlay the very rows being filtered.
|
// storefront filters status outside the drawer.
|
||||||
export default function InventoryFilters({ categories, tags, filters, onChange, onClear }: Props) {
|
const activeCount = activeFilterCount(filters) + (filters.status === null ? 0 : 1);
|
||||||
const treeData = useMemo(() => toTreeData(buildCategoryTree(categories)), [categories]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inventory-filters">
|
<div className="inventory-filters">
|
||||||
<TreeSelect
|
<Button
|
||||||
allowClear
|
icon={<FilterOutlined />}
|
||||||
showSearch
|
onClick={() => setDrawerOpen(true)}
|
||||||
treeNodeFilterProp="title"
|
type={activeCount ? 'primary' : 'default'}
|
||||||
listHeight={256}
|
>
|
||||||
placeholder="Any category"
|
Filters{activeCount ? ` (${activeCount})` : ''}
|
||||||
aria-label="Filter by category"
|
</Button>
|
||||||
style={{ minWidth: 200 }}
|
|
||||||
treeData={treeData}
|
<ActiveFilterChips
|
||||||
value={filters.categoryId ?? undefined}
|
categories={categories}
|
||||||
onChange={(value) => onChange({ ...filters, categoryId: value ?? null })}
|
tags={tags}
|
||||||
|
filters={filters}
|
||||||
|
onChange={onChange}
|
||||||
|
onClear={onClear}
|
||||||
|
showStatus
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Select
|
<FilterDrawer
|
||||||
allowClear
|
open={drawerOpen}
|
||||||
mode="multiple"
|
onClose={() => setDrawerOpen(false)}
|
||||||
placeholder="Any tags"
|
categories={categories}
|
||||||
aria-label="Filter by tags"
|
tags={tags}
|
||||||
style={{ minWidth: 200 }}
|
// No slider: the admin has no catalogue-wide price range to bound one
|
||||||
value={filters.tagIds}
|
// with, and inventing bounds would misreport where the prices are.
|
||||||
onChange={(value: number[]) => onChange({ ...filters, tagIds: value })}
|
priceRange={null}
|
||||||
options={tags.map((tag) => ({ value: tag.id, label: tag.name }))}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
import Tag from 'antd/es/tag';
|
import Tag from 'antd/es/tag';
|
||||||
import Button from 'antd/es/button';
|
import Button from 'antd/es/button';
|
||||||
import type { FilterOptions } from '../api';
|
import type { Category, Tag as ItemTag } from '../api';
|
||||||
import { ItemFilters, categoryPath, formatPriceRange, hasActiveFilters } from '../filters';
|
import { ItemFilters, categoryPath, formatPriceRange, hasActiveFilters, statusLabel } from '../filters';
|
||||||
|
|
||||||
type Props = Readonly<{
|
type Props = Readonly<{
|
||||||
options: FilterOptions | null;
|
categories: Category[];
|
||||||
|
tags: ItemTag[];
|
||||||
filters: ItemFilters;
|
filters: ItemFilters;
|
||||||
onChange: (filters: ItemFilters) => void;
|
onChange: (filters: ItemFilters) => void;
|
||||||
onClear: () => 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;
|
if (!hasActiveFilters(filters)) return null;
|
||||||
|
|
||||||
const categories = options?.categories ?? [];
|
|
||||||
const tags = options?.tags ?? [];
|
|
||||||
|
|
||||||
const chips: { key: string; label: string; onRemove: () => void }[] = [];
|
const chips: { key: string; label: string; onRemove: () => void }[] = [];
|
||||||
|
|
||||||
// Listed first so it matches the drawer's ordering, and because it is the
|
// Listed first so it matches the drawer's ordering, and because it is the
|
||||||
@@ -28,17 +37,20 @@ export default function ActiveFilterChips({ options, filters, onChange, onClear
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.categoryId !== null) {
|
// One chip per selected category, each removable on its own — removing the
|
||||||
const path = categoryPath(categories, filters.categoryId);
|
// 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
|
// Falls back to the raw id while /api/filters is still loading, so the chip
|
||||||
// never renders as an empty box.
|
// never renders as an empty box.
|
||||||
const label = path || `Category ${filters.categoryId}`;
|
const label = path || `Category ${categoryId}`;
|
||||||
chips.push({
|
chips.push({
|
||||||
key: `category-${filters.categoryId}`,
|
key: `category-${categoryId}`,
|
||||||
// The removable name is the leaf, matching what the user clicked in the
|
// The chip shows the full path for context, since two categories can
|
||||||
// tree, while the chip itself shows the full path for context.
|
// share a leaf name under different parents.
|
||||||
label,
|
label,
|
||||||
onRemove: () => onChange({ ...filters, categoryId: null })
|
onRemove: () =>
|
||||||
|
onChange({ ...filters, categoryIds: filters.categoryIds.filter((id) => id !== categoryId) })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,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) {
|
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) {
|
||||||
chips.push({
|
chips.push({
|
||||||
key: 'price',
|
key: 'price',
|
||||||
|
|||||||
@@ -1,34 +1,67 @@
|
|||||||
import Drawer from 'antd/es/drawer';
|
import Drawer from 'antd/es/drawer';
|
||||||
import Button from 'antd/es/button';
|
import Button from 'antd/es/button';
|
||||||
import Tree from 'antd/es/tree';
|
import TreeSelect from 'antd/es/tree-select';
|
||||||
|
import Select from 'antd/es/select';
|
||||||
import Tag from 'antd/es/tag';
|
import Tag from 'antd/es/tag';
|
||||||
import Slider from 'antd/es/slider';
|
import Slider from 'antd/es/slider';
|
||||||
import InputNumber from 'antd/es/input-number';
|
import InputNumber from 'antd/es/input-number';
|
||||||
import Empty from 'antd/es/empty';
|
import Empty from 'antd/es/empty';
|
||||||
import Switch from 'antd/es/switch';
|
import Switch from 'antd/es/switch';
|
||||||
import Grid from 'antd/es/grid';
|
import Grid from 'antd/es/grid';
|
||||||
import type { DataNode } from 'antd/es/tree';
|
import type { Category, Tag as ItemTag } from '../api';
|
||||||
import type { FilterOptions } from '../api';
|
import { ItemFilters, ItemStatus, STATUS_OPTIONS, buildCategoryTree, CategoryNode } from '../filters';
|
||||||
import { ItemFilters, 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<{
|
type Props = Readonly<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
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;
|
filters: ItemFilters;
|
||||||
onChange: (filters: ItemFilters) => void;
|
onChange: (filters: ItemFilters) => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
resultCount: number;
|
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;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
function toTreeData(nodes: CategoryNode[]): DataNode[] {
|
// `value` rather than `key`: this fed an antd `Tree`, which identifies nodes by
|
||||||
|
// key, and now feeds a `TreeSelect`, which selects and searches by value. The
|
||||||
|
// shape matches the admin's CategoryTreeSelect so the two stay comparable.
|
||||||
|
interface CategoryTreeOption {
|
||||||
|
value: number;
|
||||||
|
title: string;
|
||||||
|
children?: CategoryTreeOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
|
||||||
return nodes.map((node) => ({
|
return nodes.map((node) => ({
|
||||||
key: node.id,
|
value: node.id,
|
||||||
title: node.name,
|
title: node.name,
|
||||||
children: node.children.length ? toTreeData(node.children) : undefined
|
children: node.children.length ? toTreeData(node.children) : undefined
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 centsToDollars = (cents: number | null): number | null => (cents === null ? null : cents / 100);
|
||||||
const dollarsToCents = (dollars: number | null): number | null =>
|
const dollarsToCents = (dollars: number | null): number | null =>
|
||||||
dollars === null || Number.isNaN(dollars) ? null : Math.round(dollars * 100);
|
dollars === null || Number.isNaN(dollars) ? null : Math.round(dollars * 100);
|
||||||
@@ -36,30 +69,26 @@ const dollarsToCents = (dollars: number | null): number | null =>
|
|||||||
export default function FilterDrawer({
|
export default function FilterDrawer({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
options,
|
categories,
|
||||||
|
tags,
|
||||||
|
priceRange,
|
||||||
filters,
|
filters,
|
||||||
onChange,
|
onChange,
|
||||||
onClear,
|
onClear,
|
||||||
resultCount
|
resultCount,
|
||||||
|
showFavorites = false,
|
||||||
|
showStatus = false
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const screens = Grid.useBreakpoint();
|
const screens = Grid.useBreakpoint();
|
||||||
const categories = options?.categories ?? [];
|
const bounds = priceRange ?? { min_cents: 0, max_cents: 0 };
|
||||||
const tags = options?.tags ?? [];
|
|
||||||
const bounds = options?.priceRange ?? { min_cents: 0, max_cents: 0 };
|
|
||||||
|
|
||||||
function toggleTag(tagId: number) {
|
function selectCategories(ids: number[]) {
|
||||||
const next = filters.tagIds.includes(tagId)
|
onChange({ ...filters, categoryIds: ids });
|
||||||
? filters.tagIds.filter((id) => id !== tagId)
|
|
||||||
: [...filters.tagIds, tagId];
|
|
||||||
onChange({ ...filters, tagIds: next });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selecting the already-selected node clears the filter, so the tree doubles
|
// The selected pills are rendered by the Select, which is handed ids rather
|
||||||
// as its own "all items" control.
|
// than tags, so the colour has to be looked up rather than carried along.
|
||||||
function selectCategory(keys: React.Key[]) {
|
const tagColors = new Map(tags.map((tag) => [tag.id, tag.color]));
|
||||||
const picked = keys.length ? Number(keys[0]) : null;
|
|
||||||
onChange({ ...filters, categoryId: picked === filters.categoryId ? null : picked });
|
|
||||||
}
|
|
||||||
|
|
||||||
const sliderMax = Math.max(bounds.max_cents, bounds.min_cents + 100);
|
const sliderMax = Math.max(bounds.max_cents, bounds.min_cents + 100);
|
||||||
|
|
||||||
@@ -86,8 +115,9 @@ export default function FilterDrawer({
|
|||||||
here for their favorites should not have to scroll past the catalogue
|
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
|
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. */}
|
prompts them to sign in, which is how they learn favorites exist. */}
|
||||||
|
{showFavorites && (
|
||||||
<section style={{ marginBottom: 28 }}>
|
<section style={{ marginBottom: 28 }}>
|
||||||
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
<h4 style={sectionHeading}>
|
||||||
Favorites
|
Favorites
|
||||||
</h4>
|
</h4>
|
||||||
{/* Deliberately not wrapped in a <label>: antd renders the switch as a
|
{/* Deliberately not wrapped in a <label>: antd renders the switch as a
|
||||||
@@ -103,18 +133,31 @@ export default function FilterDrawer({
|
|||||||
<span>Only my favorites</span>
|
<span>Only my favorites</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section style={{ marginBottom: 28 }}>
|
<section style={{ marginBottom: 28 }}>
|
||||||
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
<h4 style={sectionHeading}>
|
||||||
Category
|
Categories — any of these
|
||||||
</h4>
|
</h4>
|
||||||
{categories.length ? (
|
{categories.length ? (
|
||||||
<Tree
|
// 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={toTreeData(buildCategoryTree(categories))}
|
treeData={toTreeData(buildCategoryTree(categories))}
|
||||||
selectedKeys={filters.categoryId === null ? [] : [filters.categoryId]}
|
value={filters.categoryIds}
|
||||||
onSelect={selectCategory}
|
onChange={selectCategories}
|
||||||
defaultExpandAll
|
multiple
|
||||||
blockNode
|
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" />
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No categories yet" />
|
||||||
@@ -122,42 +165,55 @@ export default function FilterDrawer({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section style={{ marginBottom: 28 }}>
|
<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
|
Tags — must have all of these
|
||||||
</h4>
|
</h4>
|
||||||
{tags.length ? (
|
{tags.length ? (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
// Was a wall of every tag in the system, which read fine at a dozen
|
||||||
{tags.map((tag) => {
|
// and not at a hundred. A searchable multi-select scales with the
|
||||||
const selected = filters.tagIds.includes(tag.id);
|
// taxonomy and, like the category control above it, states its
|
||||||
return (
|
// selection inside the control instead of in chip colouring.
|
||||||
// A real button rather than a styled span, so the pills are
|
//
|
||||||
// reachable by keyboard and announce their on/off state.
|
// The colours survive as the selected pills, since that is the only
|
||||||
<button
|
// place a tag's colour was ever load-bearing.
|
||||||
key={tag.id}
|
<Select
|
||||||
type="button"
|
mode="multiple"
|
||||||
aria-pressed={selected}
|
showSearch
|
||||||
onClick={() => toggleTag(tag.id)}
|
optionFilterProp="label"
|
||||||
style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
|
allowClear
|
||||||
>
|
placeholder="Any tags"
|
||||||
<Tag
|
style={{ width: '100%' }}
|
||||||
color={selected ? tag.color : undefined}
|
aria-label="Filter by tags"
|
||||||
style={{ margin: 0, opacity: selected ? 1 : 0.75 }}
|
value={filters.tagIds}
|
||||||
>
|
onChange={(tagIds: number[]) => onChange({ ...filters, tagIds })}
|
||||||
{tag.name}
|
options={tags.map((tag) => ({ value: tag.id, label: tag.name }))}
|
||||||
</Tag>
|
tagRender={({ value, label, closable, onClose }) => (
|
||||||
</button>
|
<Tag
|
||||||
);
|
color={tagColors.get(Number(value))}
|
||||||
})}
|
closable={closable}
|
||||||
</div>
|
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" />
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No tags yet" />
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section style={showStatus ? { marginBottom: 28 } : undefined}>
|
||||||
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
<h4 style={sectionHeading}>
|
||||||
Price
|
Price
|
||||||
</h4>
|
</h4>
|
||||||
|
{priceRange && (
|
||||||
<Slider
|
<Slider
|
||||||
range
|
range
|
||||||
min={bounds.min_cents}
|
min={bounds.min_cents}
|
||||||
@@ -177,6 +233,7 @@ export default function FilterDrawer({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
aria-label="Minimum price"
|
aria-label="Minimum price"
|
||||||
@@ -197,6 +254,41 @@ export default function FilterDrawer({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-5
@@ -3,7 +3,14 @@ import type { Category } from './api';
|
|||||||
export type ItemStatus = 'pending' | 'available' | 'reserved' | 'sold';
|
export type ItemStatus = 'pending' | 'available' | 'reserved' | 'sold';
|
||||||
|
|
||||||
export interface ItemFilters {
|
export interface ItemFilters {
|
||||||
categoryId: number | null;
|
// Several categories, combined as OR — picking Furniture and Decor means
|
||||||
|
// either, not the empty intersection. Deliberately the opposite of tagIds
|
||||||
|
// below, which is AND, and both controls label their rule so the difference
|
||||||
|
// is stated rather than discovered.
|
||||||
|
//
|
||||||
|
// The admin's inventory filter is single-select and holds a list of one; the
|
||||||
|
// type is shared, and one shape is better than two that drift.
|
||||||
|
categoryIds: number[];
|
||||||
tagIds: number[];
|
tagIds: number[];
|
||||||
minPriceCents: number | null;
|
minPriceCents: number | null;
|
||||||
maxPriceCents: number | null;
|
maxPriceCents: number | null;
|
||||||
@@ -69,7 +76,7 @@ export function saleStateFromStatuses(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const EMPTY_FILTERS: ItemFilters = {
|
export const EMPTY_FILTERS: ItemFilters = {
|
||||||
categoryId: null,
|
categoryIds: [],
|
||||||
tagIds: [],
|
tagIds: [],
|
||||||
minPriceCents: null,
|
minPriceCents: null,
|
||||||
maxPriceCents: null,
|
maxPriceCents: null,
|
||||||
@@ -82,7 +89,9 @@ export const EMPTY_FILTERS: ItemFilters = {
|
|||||||
// what GET /api/items accepts, so the same object serializes for both.
|
// what GET /api/items accepts, so the same object serializes for both.
|
||||||
export function filtersToSearchParams(filters: ItemFilters): URLSearchParams {
|
export function filtersToSearchParams(filters: ItemFilters): URLSearchParams {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filters.categoryId !== null) params.set('category', String(filters.categoryId));
|
// Comma-separated under the singular name it has always had, so a link
|
||||||
|
// written before this went multi-valued still means what it meant.
|
||||||
|
if (filters.categoryIds.length) params.set('category', filters.categoryIds.join(','));
|
||||||
if (filters.tagIds.length) params.set('tags', filters.tagIds.join(','));
|
if (filters.tagIds.length) params.set('tags', filters.tagIds.join(','));
|
||||||
if (filters.minPriceCents !== null) params.set('min_price', String(filters.minPriceCents));
|
if (filters.minPriceCents !== null) params.set('min_price', String(filters.minPriceCents));
|
||||||
if (filters.maxPriceCents !== null) params.set('max_price', String(filters.maxPriceCents));
|
if (filters.maxPriceCents !== null) params.set('max_price', String(filters.maxPriceCents));
|
||||||
@@ -98,6 +107,11 @@ function readInt(raw: string | null): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
|
export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
|
||||||
|
const categories = (params.get('category') || '')
|
||||||
|
.split(',')
|
||||||
|
.map((part) => readInt(part))
|
||||||
|
.filter((id): id is number => id !== null && id > 0);
|
||||||
|
|
||||||
const tags = (params.get('tags') || '')
|
const tags = (params.get('tags') || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((part) => readInt(part))
|
.map((part) => readInt(part))
|
||||||
@@ -127,7 +141,7 @@ export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
|
|||||||
const favorites = params.get('favorites');
|
const favorites = params.get('favorites');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
categoryId: readInt(params.get('category')),
|
categoryIds: categories,
|
||||||
tagIds: tags,
|
tagIds: tags,
|
||||||
minPriceCents: readInt(params.get('min_price')),
|
minPriceCents: readInt(params.get('min_price')),
|
||||||
maxPriceCents: readInt(params.get('max_price')),
|
maxPriceCents: readInt(params.get('max_price')),
|
||||||
@@ -143,9 +157,27 @@ export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
|
|||||||
// beside this button rather than living in the drawer, so counting it would put
|
// 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
|
// a number on a button whose drawer shows nothing set — and the control already
|
||||||
// displays its own position.
|
// displays its own position.
|
||||||
|
// Named individually rather than grouped, because grouping is what the preset
|
||||||
|
// this replaced did. Pending is listed first: "what is waiting to be published"
|
||||||
|
// is the question that prompted #132.
|
||||||
|
//
|
||||||
|
// Here rather than in the admin screen because the drawer and the active-filter
|
||||||
|
// chips both need to turn a status into a label, and a second copy of this list
|
||||||
|
// is a second place for a new status to be forgotten.
|
||||||
|
export const STATUS_OPTIONS: { value: ItemStatus; label: string }[] = [
|
||||||
|
{ value: 'pending', label: 'Pending' },
|
||||||
|
{ value: 'available', label: 'Available' },
|
||||||
|
{ value: 'reserved', label: 'Reserved' },
|
||||||
|
{ value: 'sold', label: 'Sold' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function statusLabel(status: ItemStatus): string {
|
||||||
|
return STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
export function activeFilterCount(filters: ItemFilters): number {
|
export function activeFilterCount(filters: ItemFilters): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
if (filters.categoryId !== null) count++;
|
count += filters.categoryIds.length;
|
||||||
count += filters.tagIds.length;
|
count += filters.tagIds.length;
|
||||||
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
|
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
|
||||||
if (filters.favoritesOnly) count++;
|
if (filters.favoritesOnly) count++;
|
||||||
|
|||||||
@@ -43,8 +43,7 @@ test.describe('Admin inventory filters', () => {
|
|||||||
await admin.goto();
|
await admin.goto();
|
||||||
await adminInventory.filterByCategory(NAMES.category, NAMES.cheap);
|
await adminInventory.filterByCategory(NAMES.category, NAMES.cheap);
|
||||||
|
|
||||||
await adminInventory.minimumPrice.fill('100');
|
await adminInventory.setPriceRange('100', '500');
|
||||||
await adminInventory.maximumPrice.fill('500');
|
|
||||||
|
|
||||||
await expect(adminInventory.row(NAMES.mid)).toBeVisible();
|
await expect(adminInventory.row(NAMES.mid)).toBeVisible();
|
||||||
await expect(adminInventory.row(NAMES.cheap)).toHaveCount(0);
|
await expect(adminInventory.row(NAMES.cheap)).toHaveCount(0);
|
||||||
@@ -111,16 +110,20 @@ test.describe('Admin inventory filters', () => {
|
|||||||
test('combines filters, and clearing restores them', async ({ admin, adminInventory }) => {
|
test('combines filters, and clearing restores them', async ({ admin, adminInventory }) => {
|
||||||
await admin.goto();
|
await admin.goto();
|
||||||
await adminInventory.filterByCategory(NAMES.category, NAMES.cheap);
|
await adminInventory.filterByCategory(NAMES.category, NAMES.cheap);
|
||||||
await adminInventory.minimumPrice.fill('800');
|
await adminInventory.setPriceRange('800');
|
||||||
await expect(adminInventory.row(NAMES.cheap)).toHaveCount(0);
|
await expect(adminInventory.row(NAMES.cheap)).toHaveCount(0);
|
||||||
await expect(adminInventory.row(NAMES.dear)).toBeVisible();
|
await expect(adminInventory.row(NAMES.dear)).toBeVisible();
|
||||||
|
|
||||||
await adminInventory.clearFiltersButton.click();
|
await adminInventory.clearFilters();
|
||||||
|
|
||||||
// Asserting on the controls rather than on the rows: with the filters gone
|
// Asserting on the controls rather than on the rows: with the filters gone
|
||||||
// the table is the whole paginated catalogue again, so a given fixture is
|
// the table is the whole paginated catalogue again, so a given fixture is
|
||||||
// not reliably on the first page.
|
// not reliably on the first page.
|
||||||
|
//
|
||||||
|
// The chip row renders only while something is filtered, and the button's
|
||||||
|
// tally is the other half of the same claim — nothing is filtered, and the
|
||||||
|
// screen says so without the drawer being opened to check.
|
||||||
await expect(adminInventory.clearFiltersButton).toHaveCount(0);
|
await expect(adminInventory.clearFiltersButton).toHaveCount(0);
|
||||||
await expect(adminInventory.minimumPrice).toHaveValue('');
|
await expect(adminInventory.filtersButton).toHaveText('Filters');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -89,6 +89,46 @@ test.describe('Storefront filters', () => {
|
|||||||
await expect(storefront.card(NAMES.midItem)).toBeHidden();
|
await expect(storefront.card(NAMES.midItem)).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('several categories combine as any-of rather than all-of', async ({
|
||||||
|
page,
|
||||||
|
storefront,
|
||||||
|
filterDrawer
|
||||||
|
}) => {
|
||||||
|
await storefront.goto();
|
||||||
|
await storefront.openFilters();
|
||||||
|
|
||||||
|
// Different branches with no items in common, so an AND would show nothing.
|
||||||
|
await filterDrawer.chooseCategories(NAMES.tables, NAMES.decor);
|
||||||
|
|
||||||
|
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
|
||||||
|
await expect(storefront.card(NAMES.otherItem)).toBeVisible();
|
||||||
|
// Filed directly in Furniture, which was not among the selections.
|
||||||
|
await expect(storefront.card(NAMES.midItem)).toBeHidden();
|
||||||
|
|
||||||
|
// Both ride in the one parameter the filter has always used, so links
|
||||||
|
// written before it went multi-valued still mean what they meant.
|
||||||
|
//
|
||||||
|
// The separator arrives percent-encoded because URLSearchParams encodes a
|
||||||
|
// comma, which is how the `tags` parameter has always looked too. Either
|
||||||
|
// spelling parses, so the assertion accepts both rather than pinning the
|
||||||
|
// encoding.
|
||||||
|
await expect(page).toHaveURL(/category=\d+(,|%2C)\d+/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each selected category gets its own removable chip', async ({ storefront, filterDrawer }) => {
|
||||||
|
await storefront.goto();
|
||||||
|
await storefront.openFilters();
|
||||||
|
await filterDrawer.chooseCategories(NAMES.tables, NAMES.decor);
|
||||||
|
await filterDrawer.close();
|
||||||
|
|
||||||
|
await storefront.removeFilterChip(NAMES.decor).click();
|
||||||
|
|
||||||
|
// Removing one chip narrows the filter to the other rather than clearing
|
||||||
|
// the category filter outright.
|
||||||
|
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
|
||||||
|
await expect(storefront.card(NAMES.otherItem)).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
test('requires every selected tag rather than any of them', async ({ storefront, filterDrawer }) => {
|
test('requires every selected tag rather than any of them', async ({ storefront, filterDrawer }) => {
|
||||||
await storefront.goto();
|
await storefront.goto();
|
||||||
await storefront.openFilters();
|
await storefront.openFilters();
|
||||||
|
|||||||
@@ -81,6 +81,31 @@ export class AdminInventory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- The inventory filter bar ----
|
// ---- The inventory filter bar ----
|
||||||
|
//
|
||||||
|
// The controls moved into the storefront's flyout (#169), so each method here
|
||||||
|
// opens the drawer, acts, and closes it again. Closing matters: the drawer
|
||||||
|
// overlays the table, and every assertion in these specs is about rows.
|
||||||
|
|
||||||
|
get filtersButton(): Locator {
|
||||||
|
return this.page.getByRole('button', { name: 'Filters' });
|
||||||
|
}
|
||||||
|
|
||||||
|
get filterDrawer(): Locator {
|
||||||
|
return this.page.getByRole('dialog', { name: 'Filters' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chip row's own "Clear all", scoped to the group so it stays distinct
|
||||||
|
* from the identically-labelled button in the drawer's footer.
|
||||||
|
*
|
||||||
|
* It exists only while something is filtered, which is what lets a spec assert
|
||||||
|
* that clearing worked by its absence.
|
||||||
|
*/
|
||||||
|
get clearFiltersButton(): Locator {
|
||||||
|
return this.page
|
||||||
|
.getByRole('group', { name: 'Active filters' })
|
||||||
|
.getByRole('button', { name: 'Clear all' });
|
||||||
|
}
|
||||||
|
|
||||||
get categoryFilter(): Locator {
|
get categoryFilter(): Locator {
|
||||||
return this.page.getByRole('combobox', { name: 'Filter by category' });
|
return this.page.getByRole('combobox', { name: 'Filter by category' });
|
||||||
@@ -98,30 +123,37 @@ export class AdminInventory {
|
|||||||
return this.page.getByLabel('Maximum price');
|
return this.page.getByLabel('Maximum price');
|
||||||
}
|
}
|
||||||
|
|
||||||
get clearFiltersButton(): Locator {
|
/** Opens the flyout, or leaves it open if it already is. */
|
||||||
return this.page.getByRole('button', { name: 'Clear filters' });
|
async openFilters(): Promise<void> {
|
||||||
|
if (await this.filterDrawer.isVisible().catch(() => false)) return;
|
||||||
|
await this.filtersButton.click();
|
||||||
|
await expect(this.filterDrawer).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes it through the footer button, which is what a person would click. */
|
||||||
|
async closeFilters(): Promise<void> {
|
||||||
|
await this.filterDrawer.getByRole('button', { name: /^Show / }).click();
|
||||||
|
await expect(this.filterDrawer).toBeHidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggles one status in the multi-select. Clicking a selected option removes
|
* Toggles one status in the multi-select. Clicking a selected option removes
|
||||||
* it, which is what the clearing test relies on.
|
* it, which is what the clearing test relies on.
|
||||||
*
|
*
|
||||||
* Two antd details decide this locator. It renders an invisible role="listbox"
|
* The option is matched by class rather than by role because antd renders an
|
||||||
* shim beside the real list for accessibility, so getByRole('option') finds
|
* invisible role="listbox" shim beside the real list for accessibility, so
|
||||||
* something zero-sized that cannot be clicked. And once a status is selected
|
* getByRole('option') finds something zero-sized that cannot be clicked; and
|
||||||
* it also renders as a tag carrying the same title as the option, so an
|
* once a status is selected it also renders as a tag carrying the same title,
|
||||||
* unscoped getByTitle becomes ambiguous. Matching the visible option class
|
* so an unscoped getByTitle becomes ambiguous.
|
||||||
* avoids both.
|
|
||||||
*
|
|
||||||
* The dropdown is opened only when it is not already open: antd keeps it open
|
|
||||||
* after a selection in multiple mode, so clicking the box again would close it.
|
|
||||||
*/
|
*/
|
||||||
async toggleStatus(label: string): Promise<void> {
|
async toggleStatus(label: string): Promise<void> {
|
||||||
|
await this.openFilters();
|
||||||
const option = this.page.locator(`.ant-select-item-option[title="${label}"]`);
|
const option = this.page.locator(`.ant-select-item-option[title="${label}"]`);
|
||||||
if (!(await option.isVisible().catch(() => false))) {
|
if (!(await option.isVisible().catch(() => false))) {
|
||||||
await this.statusFilter.click();
|
await this.statusFilter.click();
|
||||||
}
|
}
|
||||||
await option.click();
|
await option.click();
|
||||||
|
await this.closeFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,14 +163,32 @@ export class AdminInventory {
|
|||||||
* unfiltered page 1 is not a reliable place to look for a fixture. Waiting for
|
* unfiltered page 1 is not a reliable place to look for a fixture. Waiting for
|
||||||
* a known row is the action's contract — the filter has been applied when the
|
* a known row is the action's contract — the filter has been applied when the
|
||||||
* table has re-rendered under it.
|
* table has re-rendered under it.
|
||||||
|
*
|
||||||
|
* Typing the name before clicking it is not for realism: the tree is
|
||||||
|
* virtualized, so against a database holding hundreds of categories the wanted
|
||||||
|
* row is never rendered until a search narrows to it.
|
||||||
*/
|
*/
|
||||||
async filterByCategory(categoryName: string, expectedRow: string): Promise<void> {
|
async filterByCategory(categoryName: string, expectedRow: string): Promise<void> {
|
||||||
|
await this.openFilters();
|
||||||
await this.categoryFilter.click();
|
await this.categoryFilter.click();
|
||||||
await this.categoryFilter.fill(categoryName);
|
await this.categoryFilter.fill(categoryName);
|
||||||
await this.page.getByTitle(categoryName, { exact: true }).click();
|
await this.page.getByRole('treeitem', { name: categoryName }).click();
|
||||||
|
await this.closeFilters();
|
||||||
await expect(this.row(expectedRow)).toBeVisible();
|
await expect(this.row(expectedRow)).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sets either end of the price range, leaving an omitted end untouched. */
|
||||||
|
async setPriceRange(minimum?: string, maximum?: string): Promise<void> {
|
||||||
|
await this.openFilters();
|
||||||
|
if (minimum !== undefined) await this.minimumPrice.fill(minimum);
|
||||||
|
if (maximum !== undefined) await this.maximumPrice.fill(maximum);
|
||||||
|
await this.closeFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearFilters(): Promise<void> {
|
||||||
|
await this.clearFiltersButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
async openItemForm(): Promise<void> {
|
async openItemForm(): Promise<void> {
|
||||||
await this.addItemButton.click();
|
await this.addItemButton.click();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,26 @@ import { Locator, Page, expect } from '@playwright/test';
|
|||||||
/**
|
/**
|
||||||
* The storefront's filter drawer.
|
* The storefront's filter drawer.
|
||||||
*
|
*
|
||||||
* Categories are a tree rather than a list, because a category filter matches
|
* Both taxonomy filters are searchable multi-selects whose options only exist
|
||||||
* the node and everything filed beneath it — so the locator is `treeitem`, and
|
* while the list is open, so every choose/toggle here opens the list, acts, and
|
||||||
* a nested category is only reachable once the tree has loaded its data.
|
* closes it again.
|
||||||
*
|
*
|
||||||
* Tags are buttons that toggle. The rule they follow is AND, not OR: selecting
|
* Each one types the name before clicking it. Not for realism — the lists are
|
||||||
* two tags means "must have both", which is deliberately different from how
|
* virtualized, so against a database holding hundreds of categories the wanted
|
||||||
* categories combine and is the thing several tests exist to pin down.
|
* row is never rendered until a search narrows to it. Scrolling to it would be
|
||||||
|
* testing the virtual list rather than the filter.
|
||||||
|
*
|
||||||
|
* Categories keep their hierarchy — a category filter matches the node and
|
||||||
|
* everything filed beneath it — so their options are `treeitem`.
|
||||||
|
*
|
||||||
|
* Tags are flat, and their options are matched by class rather than by role,
|
||||||
|
* for the reason AdminInventory.toggleStatus records: antd renders an invisible
|
||||||
|
* role="listbox" shim beside the real list, so getByRole('option') resolves to
|
||||||
|
* something zero-sized that can never be clicked.
|
||||||
|
*
|
||||||
|
* The two follow opposite rules, which is the thing several tests exist to pin
|
||||||
|
* down: categories are OR (any of the selected branches), tags are AND (the
|
||||||
|
* item must carry all of them).
|
||||||
*/
|
*/
|
||||||
export class FilterDrawer {
|
export class FilterDrawer {
|
||||||
readonly minimumPrice: Locator;
|
readonly minimumPrice: Locator;
|
||||||
@@ -17,6 +30,9 @@ export class FilterDrawer {
|
|||||||
readonly closeButton: Locator;
|
readonly closeButton: Locator;
|
||||||
readonly clearAllButton: Locator;
|
readonly clearAllButton: Locator;
|
||||||
readonly favoritesOnlySwitch: Locator;
|
readonly favoritesOnlySwitch: Locator;
|
||||||
|
readonly categorySelect: Locator;
|
||||||
|
readonly tagSelect: Locator;
|
||||||
|
readonly title: Locator;
|
||||||
|
|
||||||
constructor(private readonly page: Page) {
|
constructor(private readonly page: Page) {
|
||||||
this.minimumPrice = page.getByLabel('Minimum price');
|
this.minimumPrice = page.getByLabel('Minimum price');
|
||||||
@@ -24,6 +40,11 @@ export class FilterDrawer {
|
|||||||
this.closeButton = page.getByRole('button', { name: 'Close' });
|
this.closeButton = page.getByRole('button', { name: 'Close' });
|
||||||
this.clearAllButton = page.getByRole('button', { name: 'Clear all' });
|
this.clearAllButton = page.getByRole('button', { name: 'Clear all' });
|
||||||
this.favoritesOnlySwitch = page.getByRole('switch', { name: 'Only my favorites' });
|
this.favoritesOnlySwitch = page.getByRole('switch', { name: 'Only my favorites' });
|
||||||
|
this.categorySelect = page.getByRole('combobox', { name: 'Filter by category' });
|
||||||
|
this.tagSelect = page.getByRole('combobox', { name: 'Filter by tags' });
|
||||||
|
// Somewhere inside the drawer that is inert and never covered by an
|
||||||
|
// option list, which opens downward from the controls below it.
|
||||||
|
this.title = page.getByRole('heading', { name: 'Favorites' });
|
||||||
}
|
}
|
||||||
|
|
||||||
category(name: string): Locator {
|
category(name: string): Locator {
|
||||||
@@ -31,15 +52,62 @@ export class FilterDrawer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tag(name: string): Locator {
|
tag(name: string): Locator {
|
||||||
return this.page.getByRole('button', { name });
|
return this.page.locator(`.ant-select-item-option[title="${name}"]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async chooseCategory(name: string): Promise<void> {
|
/**
|
||||||
|
* Opens the category list and leaves it open.
|
||||||
|
*
|
||||||
|
* Separate from choosing, so a test that picks two categories opens once —
|
||||||
|
* which is also what a customer does, the list staying open being the point
|
||||||
|
* of a multi-select.
|
||||||
|
*/
|
||||||
|
async openCategoryList(): Promise<void> {
|
||||||
|
await this.categorySelect.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrows to one category and clicks it, with the list already open.
|
||||||
|
*
|
||||||
|
* Selecting clears the search box, so consecutive calls each start from the
|
||||||
|
* unfiltered list.
|
||||||
|
*/
|
||||||
|
private async pickCategory(name: string): Promise<void> {
|
||||||
|
await this.categorySelect.fill(name);
|
||||||
await this.category(name).click();
|
await this.category(name).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes whichever option list is open, without closing the drawer.
|
||||||
|
*
|
||||||
|
* Escape would do both — the drawer listens for it too — so this clicks off
|
||||||
|
* the control instead, onto the one part of the drawer nothing overlaps.
|
||||||
|
*/
|
||||||
|
async closeOptionList(): Promise<void> {
|
||||||
|
await this.title.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async chooseCategory(name: string): Promise<void> {
|
||||||
|
await this.openCategoryList();
|
||||||
|
await this.pickCategory(name);
|
||||||
|
await this.closeOptionList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picks several categories from one opening of the list, as the UI intends. */
|
||||||
|
async chooseCategories(...names: string[]): Promise<void> {
|
||||||
|
await this.openCategoryList();
|
||||||
|
for (const name of names) {
|
||||||
|
await this.pickCategory(name);
|
||||||
|
}
|
||||||
|
await this.closeOptionList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clicking a selected option in a multi-select deselects it, so this toggles. */
|
||||||
async toggleTag(name: string): Promise<void> {
|
async toggleTag(name: string): Promise<void> {
|
||||||
|
await this.tagSelect.click();
|
||||||
|
await this.tagSelect.fill(name);
|
||||||
await this.tag(name).click();
|
await this.tag(name).click();
|
||||||
|
await this.closeOptionList();
|
||||||
}
|
}
|
||||||
|
|
||||||
async setPriceRange(minimum?: string, maximum?: string): Promise<void> {
|
async setPriceRange(minimum?: string, maximum?: string): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user