Merge branch 'main' into feature/139-searchable-multi-select-filters
Linting / lint (pull_request) Successful in 2m3s
SonarQube Analysis / sonarqube (pull_request) Failing after 5m8s

This commit is contained in:
2026-08-24 16:47:11 -05:00
8 changed files with 261 additions and 162 deletions
+6 -2
View File
@@ -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
+1
View File
@@ -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 }} />
+57 -123
View File
@@ -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,103 +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
// The filter shape went multi-valued for the storefront (#139). This categories={categories}
// control stays single-select — the admin asks "what is in this tags={tags}
// category", not "in any of these" — so it reads and writes a list of filters={filters}
// at most one rather than growing a second shape. onChange={onChange}
value={filters.categoryIds[0] ?? undefined} onClear={onClear}
// Annotated nullable because allowClear hands back undefined, which showStatus
// the control's own onChange type does not admit.
onChange={(value: number | undefined) =>
onChange({ ...filters, categoryIds: value === undefined ? [] : [value] })
}
/> />
<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>
); );
} }
+31 -7
View File
@@ -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
@@ -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) { if (filters.minPriceCents !== null || filters.maxPriceCents !== null) {
chips.push({ chips.push({
key: 'price', key: 'price',
+78 -13
View File
@@ -8,17 +8,33 @@ 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 { FilterOptions } from '../api'; import type { Category, Tag as ItemTag } from '../api';
import { ItemFilters, buildCategoryTree, CategoryNode } from '../filters'; 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<{ 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;
}>; }>;
// `value` rather than `key`: this fed an antd `Tree`, which identifies nodes by // `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 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);
@@ -45,16 +69,18 @@ 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 selectCategories(ids: number[]) { function selectCategories(ids: number[]) {
onChange({ ...filters, categoryIds: ids }); onChange({ ...filters, categoryIds: ids });
@@ -89,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
@@ -106,9 +133,10 @@ 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}>
Categories any of these Categories any of these
</h4> </h4>
{categories.length ? ( {categories.length ? (
@@ -137,7 +165,7 @@ 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 of these Tags must have all of these
</h4> </h4>
{tags.length ? ( {tags.length ? (
@@ -181,10 +209,11 @@ export default function FilterDrawer({
)} )}
</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}
@@ -204,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"
@@ -224,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>
); );
} }
+18
View File
@@ -157,6 +157,24 @@ 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;
count += filters.categoryIds.length; count += filters.categoryIds.length;
@@ -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');
}); });
}); });
+62 -12
View File
@@ -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();
} }