feat(filters): make the storefront filter panel searchable and multi-select (#139)
The filter drawer did not scale with the taxonomy behind it. Categories were a bare antd `Tree` rendered at whatever depth it had grown to, with no search and single selection, and tags were a wall of every tag in the system. Neither said what was selected except through highlighting and chip colour. Both are now searchable multi-selects. Categories keep their hierarchy in a `TreeSelect`, matching the admin's `CategoryTreeSelect` so the two screens behave alike; tags become a multiple `Select` whose selected pills keep their colours, which is the only place a tag's colour was load-bearing. Several categories combine as OR. A customer picking Furniture and Decor wants both, not the empty intersection, and each selected id still expands to its descendants, so the answer is the union of the subtrees. That is deliberately the opposite of the tag rule, which stays AND, and both headings now state their rule rather than leaving it to be discovered. `ItemFilters.categoryId` becomes `categoryIds` end to end. The recursive CTE is seeded with `= ANY($n::int[])` rather than one id, which walks every selected root in one recursion and gives the OR for free; matching on `IN` keeps it a set test, so an item under two selected branches still appears once. The query parameter keeps its singular name and becomes comma-separated, the shape `tags` and `status` already use, so every `?category=1` link written before this still parses as a list of one. A list containing anything unreadable is still a 400, per decision 9 — honouring the readable half would answer a narrower question than the one asked and look indistinguishable from a filter that worked. The admin's inventory filter stays single-select, since it asks what is in a category rather than in any of several, but reads and writes a list of at most one so there is one shared filter type rather than two that drift. Closes #139
This commit is contained in:
@@ -89,6 +89,41 @@ test.describe('Storefront filters', () => {
|
||||
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.
|
||||
await expect(page).toHaveURL(/category=\d+,\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 }) => {
|
||||
await storefront.goto();
|
||||
await storefront.openFilters();
|
||||
|
||||
@@ -3,13 +3,17 @@ import { Locator, Page, expect } from '@playwright/test';
|
||||
/**
|
||||
* The storefront's filter drawer.
|
||||
*
|
||||
* Categories are a tree rather than a list, because a category filter matches
|
||||
* the node and everything filed beneath it — so the locator is `treeitem`, and
|
||||
* a nested category is only reachable once the tree has loaded its data.
|
||||
* Both taxonomy filters are searchable multi-selects whose options only exist
|
||||
* while the list is open, so every choose/toggle here opens the list, acts, and
|
||||
* closes it again.
|
||||
*
|
||||
* Tags are buttons that toggle. The rule they follow is AND, not OR: selecting
|
||||
* two tags means "must have both", which is deliberately different from how
|
||||
* categories combine and is the thing several tests exist to pin down.
|
||||
* Categories keep their hierarchy — a category filter matches the node and
|
||||
* everything filed beneath it — so their options are `treeitem`. Tags are flat,
|
||||
* so theirs are `option`.
|
||||
*
|
||||
* 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 {
|
||||
readonly minimumPrice: Locator;
|
||||
@@ -17,6 +21,9 @@ export class FilterDrawer {
|
||||
readonly closeButton: Locator;
|
||||
readonly clearAllButton: Locator;
|
||||
readonly favoritesOnlySwitch: Locator;
|
||||
readonly categorySelect: Locator;
|
||||
readonly tagSelect: Locator;
|
||||
readonly title: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.minimumPrice = page.getByLabel('Minimum price');
|
||||
@@ -24,6 +31,9 @@ export class FilterDrawer {
|
||||
this.closeButton = page.getByRole('button', { name: 'Close' });
|
||||
this.clearAllButton = page.getByRole('button', { name: 'Clear all' });
|
||||
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' });
|
||||
this.title = page.getByRole('heading', { name: 'Filters' });
|
||||
}
|
||||
|
||||
category(name: string): Locator {
|
||||
@@ -31,15 +41,50 @@ export class FilterDrawer {
|
||||
}
|
||||
|
||||
tag(name: string): Locator {
|
||||
return this.page.getByRole('button', { name });
|
||||
return this.page.getByRole('option', { name });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.category(name).click();
|
||||
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.category(name).click();
|
||||
}
|
||||
await this.closeOptionList();
|
||||
}
|
||||
|
||||
/** Clicking a selected option in a multi-select deselects it, so this toggles. */
|
||||
async toggleTag(name: string): Promise<void> {
|
||||
await this.tagSelect.click();
|
||||
await this.tag(name).click();
|
||||
await this.closeOptionList();
|
||||
}
|
||||
|
||||
async setPriceRange(minimum?: string, maximum?: string): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user