Feature/169 admin filter flyout #171

Merged
bermudalamb merged 3 commits from feature/169-admin-filter-flyout into main 2026-08-24 16:46:53 -05:00
3 changed files with 46 additions and 10 deletions
Showing only changes of commit 856d8c4511 - Show all commits
+11 -3
View File
@@ -8,7 +8,6 @@ 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 { FilterOptions } from '../api'; import type { FilterOptions } from '../api';
import { ItemFilters, buildCategoryTree, CategoryNode } from '../filters'; import { ItemFilters, buildCategoryTree, CategoryNode } from '../filters';
@@ -22,9 +21,18 @@ type Props = Readonly<{
resultCount: number; resultCount: number;
}>; }>;
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
})); }));
+6 -1
View File
@@ -107,7 +107,12 @@ test.describe('Storefront filters', () => {
// Both ride in the one parameter the filter has always used, so links // Both ride in the one parameter the filter has always used, so links
// written before it went multi-valued still mean what they meant. // written before it went multi-valued still mean what they meant.
await expect(page).toHaveURL(/category=\d+,\d+/); //
// 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 }) => { test('each selected category gets its own removable chip', async ({ storefront, filterDrawer }) => {
+29 -6
View File
@@ -7,9 +7,18 @@ import { Locator, Page, expect } from '@playwright/test';
* while the list is open, so every choose/toggle here opens the list, acts, and * while the list is open, so every choose/toggle here opens the list, acts, and
* closes it again. * closes it again.
* *
* Each one types the name before clicking it. Not for realism — the lists are
* virtualized, so against a database holding hundreds of categories the wanted
* 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 * Categories keep their hierarchy — a category filter matches the node and
* everything filed beneath it — so their options are `treeitem`. Tags are flat, * everything filed beneath it — so their options are `treeitem`.
* so theirs are `option`. *
* 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 * 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 * down: categories are OR (any of the selected branches), tags are AND (the
@@ -33,7 +42,9 @@ export class FilterDrawer {
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.categorySelect = page.getByRole('combobox', { name: 'Filter by category' });
this.tagSelect = page.getByRole('combobox', { name: 'Filter by tags' }); this.tagSelect = page.getByRole('combobox', { name: 'Filter by tags' });
this.title = page.getByRole('heading', { name: 'Filters' }); // 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 {
@@ -41,7 +52,7 @@ export class FilterDrawer {
} }
tag(name: string): Locator { tag(name: string): Locator {
return this.page.getByRole('option', { name }); return this.page.locator(`.ant-select-item-option[title="${name}"]`);
} }
/** /**
@@ -55,6 +66,17 @@ export class FilterDrawer {
await this.categorySelect.click(); 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();
}
/** /**
* Closes whichever option list is open, without closing the drawer. * Closes whichever option list is open, without closing the drawer.
* *
@@ -67,7 +89,7 @@ export class FilterDrawer {
async chooseCategory(name: string): Promise<void> { async chooseCategory(name: string): Promise<void> {
await this.openCategoryList(); await this.openCategoryList();
await this.category(name).click(); await this.pickCategory(name);
await this.closeOptionList(); await this.closeOptionList();
} }
@@ -75,7 +97,7 @@ export class FilterDrawer {
async chooseCategories(...names: string[]): Promise<void> { async chooseCategories(...names: string[]): Promise<void> {
await this.openCategoryList(); await this.openCategoryList();
for (const name of names) { for (const name of names) {
await this.category(name).click(); await this.pickCategory(name);
} }
await this.closeOptionList(); await this.closeOptionList();
} }
@@ -83,6 +105,7 @@ export class FilterDrawer {
/** Clicking a selected option in a multi-select deselects it, so this toggles. */ /** 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.click();
await this.tagSelect.fill(name);
await this.tag(name).click(); await this.tag(name).click();
await this.closeOptionList(); await this.closeOptionList();
} }