Merge branch 'main' into feature/139-searchable-multi-select-filters
This commit is contained in:
@@ -230,7 +230,8 @@ export default function App() {
|
||||
Filters{activeCount ? ` (${activeCount})` : ''}
|
||||
</Button>
|
||||
<ActiveFilterChips
|
||||
options={options}
|
||||
categories={options?.categories ?? []}
|
||||
tags={options?.tags ?? []}
|
||||
filters={filters}
|
||||
onChange={applyFilters}
|
||||
onClear={clearFilters}
|
||||
@@ -280,11 +281,14 @@ export default function App() {
|
||||
<FilterDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
options={options}
|
||||
categories={options?.categories ?? []}
|
||||
tags={options?.tags ?? []}
|
||||
priceRange={options?.priceRange ?? null}
|
||||
filters={filters}
|
||||
onChange={applyFilters}
|
||||
onClear={clearFilters}
|
||||
resultCount={items.length}
|
||||
showFavorites
|
||||
/>
|
||||
|
||||
{/* The same prompt the heart button and Add to Cart use. Signing in
|
||||
|
||||
@@ -275,6 +275,7 @@ function Inventory() {
|
||||
filters={filters}
|
||||
onChange={applyFilters}
|
||||
onClear={clearFilters}
|
||||
resultCount={items.length}
|
||||
/>
|
||||
|
||||
<Table rowKey="id" dataSource={items} columns={columns} scroll={{ x: true }} />
|
||||
|
||||
@@ -1,40 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import TreeSelect from 'antd/es/tree-select';
|
||||
import Select from 'antd/es/select';
|
||||
import InputNumber from 'antd/es/input-number';
|
||||
import { useState } from 'react';
|
||||
import Button from 'antd/es/button';
|
||||
import { FilterOutlined } from '@ant-design/icons';
|
||||
import type { Category, Tag } from '../api';
|
||||
import {
|
||||
ItemFilters,
|
||||
ItemStatus,
|
||||
buildCategoryTree,
|
||||
CategoryNode,
|
||||
hasActiveFilters
|
||||
} from '../filters';
|
||||
|
||||
// Named individually rather than grouped, because grouping is what the preset
|
||||
// this replaces did. Pending is listed first: "what is waiting to be published"
|
||||
// is the question that prompted #132.
|
||||
const STATUS_OPTIONS: { value: ItemStatus; label: string }[] = [
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'available', label: 'Available' },
|
||||
{ value: 'reserved', label: 'Reserved' },
|
||||
{ value: 'sold', label: 'Sold' }
|
||||
];
|
||||
|
||||
interface CategoryTreeOption {
|
||||
value: number;
|
||||
title: string;
|
||||
children?: CategoryTreeOption[];
|
||||
}
|
||||
|
||||
function toTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
|
||||
return nodes.map((node) => ({
|
||||
value: node.id,
|
||||
title: node.name,
|
||||
children: node.children.length ? toTreeData(node.children) : undefined
|
||||
}));
|
||||
}
|
||||
import { ItemFilters, activeFilterCount } from '../filters';
|
||||
import FilterDrawer from '../components/FilterDrawer';
|
||||
import ActiveFilterChips from '../components/ActiveFilterChips';
|
||||
|
||||
type Props = Readonly<{
|
||||
categories: Category[];
|
||||
@@ -42,103 +12,67 @@ type Props = Readonly<{
|
||||
filters: ItemFilters;
|
||||
onChange: (filters: ItemFilters) => void;
|
||||
onClear: () => void;
|
||||
resultCount: number;
|
||||
}>;
|
||||
|
||||
const centsToDollars = (cents: number | null): number | null => (cents === null ? null : cents / 100);
|
||||
const dollarsToCents = (dollars: number | null): number | null =>
|
||||
dollars === null || Number.isNaN(dollars) ? null : Math.round(dollars * 100);
|
||||
// The same flyout the storefront uses, rather than the row of controls this
|
||||
// used to be (#169).
|
||||
//
|
||||
// That row was deliberate — it carried a comment arguing that hiding the
|
||||
// controls above a data table costs more than the space it saves, and that a
|
||||
// drawer overlays the very rows being filtered. Both are true, and both are
|
||||
// traded for the two screens asking the same questions through the same UI.
|
||||
// The chips are what makes the trade bearable: the active filter stays readable
|
||||
// beside the button without opening anything, which is what the always-visible
|
||||
// row was really protecting.
|
||||
export default function InventoryFilters({
|
||||
categories,
|
||||
tags,
|
||||
filters,
|
||||
onChange,
|
||||
onClear,
|
||||
resultCount
|
||||
}: Props) {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
// An always-visible row rather than the storefront's drawer: this sits above a
|
||||
// data table, where hiding the controls behind a click costs more than the
|
||||
// space it saves, and a drawer would overlay the very rows being filtered.
|
||||
export default function InventoryFilters({ categories, tags, filters, onChange, onClear }: Props) {
|
||||
const treeData = useMemo(() => toTreeData(buildCategoryTree(categories)), [categories]);
|
||||
// Status lives in the drawer here, unlike on the storefront, so it belongs in
|
||||
// the button's tally — activeFilterCount leaves it out precisely because the
|
||||
// storefront filters status outside the drawer.
|
||||
const activeCount = activeFilterCount(filters) + (filters.status === null ? 0 : 1);
|
||||
|
||||
return (
|
||||
<div className="inventory-filters">
|
||||
<TreeSelect
|
||||
allowClear
|
||||
showSearch
|
||||
treeNodeFilterProp="title"
|
||||
listHeight={256}
|
||||
placeholder="Any category"
|
||||
aria-label="Filter by category"
|
||||
style={{ minWidth: 200 }}
|
||||
treeData={treeData}
|
||||
// The filter shape went multi-valued for the storefront (#139). This
|
||||
// control stays single-select — the admin asks "what is in this
|
||||
// category", not "in any of these" — so it reads and writes a list of
|
||||
// at most one rather than growing a second shape.
|
||||
value={filters.categoryIds[0] ?? undefined}
|
||||
// Annotated nullable because allowClear hands back undefined, which
|
||||
// the control's own onChange type does not admit.
|
||||
onChange={(value: number | undefined) =>
|
||||
onChange({ ...filters, categoryIds: value === undefined ? [] : [value] })
|
||||
}
|
||||
<Button
|
||||
icon={<FilterOutlined />}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
type={activeCount ? 'primary' : 'default'}
|
||||
>
|
||||
Filters{activeCount ? ` (${activeCount})` : ''}
|
||||
</Button>
|
||||
|
||||
<ActiveFilterChips
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
filters={filters}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
showStatus
|
||||
/>
|
||||
|
||||
<Select
|
||||
allowClear
|
||||
mode="multiple"
|
||||
placeholder="Any tags"
|
||||
aria-label="Filter by tags"
|
||||
style={{ minWidth: 200 }}
|
||||
value={filters.tagIds}
|
||||
onChange={(value: number[]) => onChange({ ...filters, tagIds: value })}
|
||||
options={tags.map((tag) => ({ value: tag.id, label: tag.name }))}
|
||||
<FilterDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
// No slider: the admin has no catalogue-wide price range to bound one
|
||||
// with, and inventing bounds would misreport where the prices are.
|
||||
priceRange={null}
|
||||
filters={filters}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
resultCount={resultCount}
|
||||
showStatus
|
||||
/>
|
||||
|
||||
<InputNumber
|
||||
aria-label="Minimum price"
|
||||
prefix="$"
|
||||
min={0}
|
||||
placeholder="Min"
|
||||
style={{ width: 110 }}
|
||||
value={centsToDollars(filters.minPriceCents)}
|
||||
onChange={(value) => onChange({ ...filters, minPriceCents: dollarsToCents(value) })}
|
||||
/>
|
||||
<span style={{ opacity: 0.6 }}>to</span>
|
||||
<InputNumber
|
||||
aria-label="Maximum price"
|
||||
prefix="$"
|
||||
min={0}
|
||||
placeholder="Max"
|
||||
style={{ width: 110 }}
|
||||
value={centsToDollars(filters.maxPriceCents)}
|
||||
onChange={(value) => onChange({ ...filters, maxPriceCents: dollarsToCents(value) })}
|
||||
/>
|
||||
|
||||
{/* The status dimension itself rather than presets over it, which #105's
|
||||
Sold / Not sold / All control was. Presets could not express Published
|
||||
or Unpublished, could not isolate Reserved, and would have grown a new
|
||||
button for every new question. Selecting statuses answers all of them:
|
||||
Unpublished is Pending, Published is the other three, and Not sold is
|
||||
everything except Sold.
|
||||
|
||||
A second control for publication would have read more naturally and
|
||||
reintroduced what #105 avoided — Sold and Unpublished is an impossible
|
||||
pair, since a sold item is necessarily published. One dimension cannot
|
||||
contradict itself. See #132.
|
||||
|
||||
The storefront keeps the three-way preset: pending is excluded from
|
||||
every public read, so Published and Unpublished are not distinctions a
|
||||
customer can draw. */}
|
||||
<Select
|
||||
allowClear
|
||||
mode="multiple"
|
||||
placeholder="Any status"
|
||||
aria-label="Filter by status"
|
||||
style={{ minWidth: 220 }}
|
||||
value={filters.status ?? []}
|
||||
onChange={(value: ItemStatus[]) =>
|
||||
// Empty means no filter, not "no statuses". A multi-select cleared
|
||||
// back to nothing should show everything rather than an empty table.
|
||||
onChange({ ...filters, status: value.length ? value : null })
|
||||
}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
|
||||
{hasActiveFilters(filters) && <Button onClick={onClear}>Clear filters</Button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import Tag from 'antd/es/tag';
|
||||
import Button from 'antd/es/button';
|
||||
import type { FilterOptions } from '../api';
|
||||
import { ItemFilters, categoryPath, formatPriceRange, hasActiveFilters } from '../filters';
|
||||
import type { Category, Tag as ItemTag } from '../api';
|
||||
import { ItemFilters, categoryPath, formatPriceRange, hasActiveFilters, statusLabel } from '../filters';
|
||||
|
||||
type Props = Readonly<{
|
||||
options: FilterOptions | null;
|
||||
categories: Category[];
|
||||
tags: ItemTag[];
|
||||
filters: ItemFilters;
|
||||
onChange: (filters: ItemFilters) => void;
|
||||
onClear: () => void;
|
||||
// Where status is one of the drawer's controls rather than a preset beside
|
||||
// it (#169), it needs a chip too — otherwise the one filter most likely to
|
||||
// empty a table is the one filter invisible without opening the drawer.
|
||||
showStatus?: boolean;
|
||||
}>;
|
||||
|
||||
export default function ActiveFilterChips({ options, filters, onChange, onClear }: Props) {
|
||||
export default function ActiveFilterChips({
|
||||
categories,
|
||||
tags,
|
||||
filters,
|
||||
onChange,
|
||||
onClear,
|
||||
showStatus = false
|
||||
}: Props) {
|
||||
if (!hasActiveFilters(filters)) return null;
|
||||
|
||||
const categories = options?.categories ?? [];
|
||||
const tags = options?.tags ?? [];
|
||||
|
||||
const chips: { key: string; label: string; onRemove: () => void }[] = [];
|
||||
|
||||
// Listed first so it matches the drawer's ordering, and because it is the
|
||||
@@ -54,6 +63,21 @@ export default function ActiveFilterChips({ options, filters, onChange, onClear
|
||||
});
|
||||
}
|
||||
|
||||
if (showStatus && filters.status !== null) {
|
||||
for (const status of filters.status) {
|
||||
chips.push({
|
||||
key: `status-${status}`,
|
||||
label: statusLabel(status),
|
||||
onRemove: () => {
|
||||
const rest = (filters.status ?? []).filter((value) => value !== status);
|
||||
// Back to null rather than an empty list: emptying the control means
|
||||
// "no status filter", not "no statuses", which would empty the table.
|
||||
onChange({ ...filters, status: rest.length ? rest : null });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) {
|
||||
chips.push({
|
||||
key: 'price',
|
||||
|
||||
@@ -8,17 +8,33 @@ import InputNumber from 'antd/es/input-number';
|
||||
import Empty from 'antd/es/empty';
|
||||
import Switch from 'antd/es/switch';
|
||||
import Grid from 'antd/es/grid';
|
||||
import type { FilterOptions } from '../api';
|
||||
import { ItemFilters, buildCategoryTree, CategoryNode } from '../filters';
|
||||
import type { Category, Tag as ItemTag } from '../api';
|
||||
import { ItemFilters, ItemStatus, STATUS_OPTIONS, buildCategoryTree, CategoryNode } from '../filters';
|
||||
|
||||
// One drawer for the storefront and the admin, with the sections that differ
|
||||
// driven by props rather than by a second component that would drift (#169).
|
||||
// What is shared is not just the markup but the phrasing of the rules — that
|
||||
// categories are OR and tags are AND has to read the same on both screens or it
|
||||
// stops being one rule.
|
||||
type Props = Readonly<{
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
options: FilterOptions | null;
|
||||
categories: Category[];
|
||||
tags: ItemTag[];
|
||||
// Bounds for the price slider, or null on a screen with no catalogue-wide
|
||||
// range to draw one from, where the two number inputs stand alone. A slider
|
||||
// needs real bounds: invented ones would misreport where the prices are.
|
||||
priceRange: { min_cents: number; max_cents: number } | null;
|
||||
filters: ItemFilters;
|
||||
onChange: (filters: ItemFilters) => void;
|
||||
onClear: () => void;
|
||||
resultCount: number;
|
||||
// Storefront only — signing in is what makes favorites mean anything.
|
||||
showFavorites?: boolean;
|
||||
// Admin only. The storefront keeps its three-way preset outside the drawer:
|
||||
// pending is excluded from every public read, so Published and Unpublished
|
||||
// are not distinctions a customer can draw.
|
||||
showStatus?: boolean;
|
||||
}>;
|
||||
|
||||
// `value` rather than `key`: this fed an antd `Tree`, which identifies nodes by
|
||||
@@ -38,6 +54,14 @@ function toTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
|
||||
}));
|
||||
}
|
||||
|
||||
const sectionHeading: React.CSSProperties = {
|
||||
margin: '0 0 8px',
|
||||
fontSize: 12,
|
||||
letterSpacing: '.06em',
|
||||
textTransform: 'uppercase',
|
||||
opacity: 0.65
|
||||
};
|
||||
|
||||
const centsToDollars = (cents: number | null): number | null => (cents === null ? null : cents / 100);
|
||||
const dollarsToCents = (dollars: number | null): number | null =>
|
||||
dollars === null || Number.isNaN(dollars) ? null : Math.round(dollars * 100);
|
||||
@@ -45,16 +69,18 @@ const dollarsToCents = (dollars: number | null): number | null =>
|
||||
export default function FilterDrawer({
|
||||
open,
|
||||
onClose,
|
||||
options,
|
||||
categories,
|
||||
tags,
|
||||
priceRange,
|
||||
filters,
|
||||
onChange,
|
||||
onClear,
|
||||
resultCount
|
||||
resultCount,
|
||||
showFavorites = false,
|
||||
showStatus = false
|
||||
}: Props) {
|
||||
const screens = Grid.useBreakpoint();
|
||||
const categories = options?.categories ?? [];
|
||||
const tags = options?.tags ?? [];
|
||||
const bounds = options?.priceRange ?? { min_cents: 0, max_cents: 0 };
|
||||
const bounds = priceRange ?? { min_cents: 0, max_cents: 0 };
|
||||
|
||||
function selectCategories(ids: number[]) {
|
||||
onChange({ ...filters, categoryIds: ids });
|
||||
@@ -89,8 +115,9 @@ export default function FilterDrawer({
|
||||
here for their favorites should not have to scroll past the catalogue
|
||||
controls to find it. Shown to signed-out visitors too: switching it on
|
||||
prompts them to sign in, which is how they learn favorites exist. */}
|
||||
{showFavorites && (
|
||||
<section style={{ marginBottom: 28 }}>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||
<h4 style={sectionHeading}>
|
||||
Favorites
|
||||
</h4>
|
||||
{/* Deliberately not wrapped in a <label>: antd renders the switch as a
|
||||
@@ -106,9 +133,10 @@ export default function FilterDrawer({
|
||||
<span>Only my favorites</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section style={{ marginBottom: 28 }}>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||
<h4 style={sectionHeading}>
|
||||
Categories — any of these
|
||||
</h4>
|
||||
{categories.length ? (
|
||||
@@ -137,7 +165,7 @@ export default function FilterDrawer({
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: 28 }}>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||
<h4 style={sectionHeading}>
|
||||
Tags — must have all of these
|
||||
</h4>
|
||||
{tags.length ? (
|
||||
@@ -181,10 +209,11 @@ export default function FilterDrawer({
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||
<section style={showStatus ? { marginBottom: 28 } : undefined}>
|
||||
<h4 style={sectionHeading}>
|
||||
Price
|
||||
</h4>
|
||||
{priceRange && (
|
||||
<Slider
|
||||
range
|
||||
min={bounds.min_cents}
|
||||
@@ -204,6 +233,7 @@ export default function FilterDrawer({
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
|
||||
<InputNumber
|
||||
aria-label="Minimum price"
|
||||
@@ -224,6 +254,41 @@ export default function FilterDrawer({
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{showStatus && (
|
||||
<section>
|
||||
<h4 style={sectionHeading}>
|
||||
Status — any of these
|
||||
</h4>
|
||||
{/* The status dimension itself rather than presets over it, which
|
||||
#105's Sold / Not sold / All control was. Presets could not express
|
||||
Published or Unpublished, could not isolate Reserved, and would
|
||||
have grown a new button for every new question. Selecting statuses
|
||||
answers all of them: Unpublished is Pending, Published is the other
|
||||
three, and Not sold is everything except Sold.
|
||||
|
||||
A second control for publication would have read more naturally and
|
||||
reintroduced what #105 avoided — Sold and Unpublished is an
|
||||
impossible pair, since a sold item is necessarily published. One
|
||||
dimension cannot contradict itself. See #132. */}
|
||||
<Select
|
||||
allowClear
|
||||
mode="multiple"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="Any status"
|
||||
aria-label="Filter by status"
|
||||
style={{ width: '100%' }}
|
||||
value={filters.status ?? []}
|
||||
onChange={(value: ItemStatus[]) =>
|
||||
// Empty means no filter, not "no statuses". A multi-select cleared
|
||||
// back to nothing should show everything rather than an empty table.
|
||||
onChange({ ...filters, status: value.length ? value : null })
|
||||
}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,6 +157,24 @@ export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
|
||||
// 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
|
||||
// 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 {
|
||||
let count = 0;
|
||||
count += filters.categoryIds.length;
|
||||
|
||||
@@ -43,8 +43,7 @@ test.describe('Admin inventory filters', () => {
|
||||
await admin.goto();
|
||||
await adminInventory.filterByCategory(NAMES.category, NAMES.cheap);
|
||||
|
||||
await adminInventory.minimumPrice.fill('100');
|
||||
await adminInventory.maximumPrice.fill('500');
|
||||
await adminInventory.setPriceRange('100', '500');
|
||||
|
||||
await expect(adminInventory.row(NAMES.mid)).toBeVisible();
|
||||
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 }) => {
|
||||
await admin.goto();
|
||||
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.dear)).toBeVisible();
|
||||
|
||||
await adminInventory.clearFiltersButton.click();
|
||||
await adminInventory.clearFilters();
|
||||
|
||||
// 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
|
||||
// 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.minimumPrice).toHaveValue('');
|
||||
await expect(adminInventory.filtersButton).toHaveText('Filters');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,6 +81,31 @@ export class AdminInventory {
|
||||
}
|
||||
|
||||
// ---- 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 {
|
||||
return this.page.getByRole('combobox', { name: 'Filter by category' });
|
||||
@@ -98,30 +123,37 @@ export class AdminInventory {
|
||||
return this.page.getByLabel('Maximum price');
|
||||
}
|
||||
|
||||
get clearFiltersButton(): Locator {
|
||||
return this.page.getByRole('button', { name: 'Clear filters' });
|
||||
/** Opens the flyout, or leaves it open if it already is. */
|
||||
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
|
||||
* it, which is what the clearing test relies on.
|
||||
*
|
||||
* Two antd details decide this locator. It renders an invisible role="listbox"
|
||||
* shim beside the real list for accessibility, so getByRole('option') finds
|
||||
* something zero-sized that cannot be clicked. And once a status is selected
|
||||
* it also renders as a tag carrying the same title as the option, so an
|
||||
* unscoped getByTitle becomes ambiguous. Matching the visible option class
|
||||
* 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.
|
||||
* The option is matched by class rather than by role because antd renders an
|
||||
* invisible role="listbox" shim beside the real list for accessibility, so
|
||||
* getByRole('option') finds something zero-sized that cannot be clicked; and
|
||||
* once a status is selected it also renders as a tag carrying the same title,
|
||||
* so an unscoped getByTitle becomes ambiguous.
|
||||
*/
|
||||
async toggleStatus(label: string): Promise<void> {
|
||||
await this.openFilters();
|
||||
const option = this.page.locator(`.ant-select-item-option[title="${label}"]`);
|
||||
if (!(await option.isVisible().catch(() => false))) {
|
||||
await this.statusFilter.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
|
||||
* a known row is the action's contract — the filter has been applied when the
|
||||
* 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> {
|
||||
await this.openFilters();
|
||||
await this.categoryFilter.click();
|
||||
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();
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
await this.addItemButton.click();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user