feat(ui): storefront filters and admin category/tag management (#23)
SonarQube Analysis / sonarqube (pull_request) Successful in 2m49s
Tests / backend-unit (pull_request) Successful in 37s
Tests / backend-integration (pull_request) Failing after 3h2m23s
Tests / frontend-e2e (pull_request) Failing after 3m54s

Storefront gains a Filters drawer holding the category tree, colour-coded
tag pills, and a price range, with applied filters shown as removable
chips. Filter state lives in the URL query string, so a filtered view is
shareable and the back button works. Item cards now show their category
and tags.

Admin gains Categories and Tags tabs, and the item form gains a category
TreeSelect plus a tags Select that creates new tags on the fly.

The admin category tree tracks expansion in state rather than using
defaultExpandAll: that prop is evaluated once at mount, so a branch added
afterwards rendered collapsed and its children were unreachable. Creating
or moving a node now expands its parent. Caught by the new admin e2e spec.

The chip row is marked as a named group so its "Clear all" stays
distinguishable from the drawer's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 09:34:37 -05:00
co-authored by Claude Opus 5
parent 9222e97deb
commit d28fb5634a
13 changed files with 1374 additions and 22 deletions
+62
View File
@@ -0,0 +1,62 @@
import { test, expect } from '@playwright/test';
// The e2e database is shared and never reset, so every fixture name carries a
// unique suffix and assertions are scoped to the nodes this run created. The
// suffix is generated per test rather than per module: a worker can run this
// file more than once, and a module-level constant would collide with itself.
const suffix = () => `a${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
test.describe('Admin taxonomy', () => {
test('creates a category and a nested child that stays visible', async ({ page }) => {
const RUN = suffix();
await page.goto('/admin');
await page.getByRole('tab', { name: 'Categories' }).click();
await page.getByRole('button', { name: 'Add Category' }).click();
await page.getByLabel('Name').fill(`Furniture ${RUN}`);
await page.getByRole('button', { name: 'OK' }).click();
await expect(page.getByText(`Furniture ${RUN}`)).toBeVisible();
await page
.getByRole('treeitem')
.filter({ hasText: `Furniture ${RUN}` })
.getByRole('button', { name: 'Add child' })
.click();
await page.getByLabel('Name').fill(`Tables ${RUN}`);
await page.getByRole('button', { name: 'OK' }).click();
// The tree is mounted before this branch exists, so the child is only
// visible if expansion follows newly created nodes rather than the state
// captured at first render.
await expect(page.getByText(`Tables ${RUN}`)).toBeVisible();
});
test('creates a tag with an automatically assigned colour', async ({ page }) => {
const RUN = suffix();
await page.goto('/admin');
await page.getByRole('tab', { name: 'Tags' }).click();
await page.getByRole('button', { name: 'Add Tag' }).click();
await page.getByLabel('Name').fill(`vintage-${RUN}`);
await page.getByRole('button', { name: 'OK' }).click();
// Clicking OK only dispatches the request; wait for the confirmation so the
// lookup below can't race the create.
await expect(page.getByText('Tag added')).toBeVisible();
// The table paginates and the shared database holds many tags, so the new
// row is confirmed through the API rather than hunted for across pages.
const tags = await (await page.request.get('/api/admin/tags')).json();
const created = tags.find((tag: { name: string }) => tag.name === `vintage-${RUN}`);
expect(created).toBeTruthy();
expect(created.color).toBeTruthy();
});
test('offers category and tag fields on the item form', async ({ page }) => {
await page.goto('/admin');
await page.getByRole('button', { name: 'Add Item' }).click();
const modal = page.getByRole('dialog');
await expect(modal.getByText('Category', { exact: true })).toBeVisible();
await expect(modal.getByText('Pick existing tags')).toBeVisible();
});
});
+195
View File
@@ -0,0 +1,195 @@
import { test, expect, APIRequestContext } from '@playwright/test';
// The storefront shows every item ever seeded, and the e2e database is not
// reset between runs. Every fixture below is therefore suffixed with a unique
// run id so assertions can name exactly the items this run created.
// Playwright runs beforeAll once per worker, so the suffix mixes a timestamp
// with randomness — two workers starting in the same millisecond would
// otherwise seed colliding category names and 409 against each other.
const RUN = `f${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const NAMES = {
furniture: `Furniture ${RUN}`,
tables: `Tables ${RUN}`,
decor: `Decor ${RUN}`,
vintage: `vintage-${RUN}`,
oak: `oak-${RUN}`,
deepItem: `Deep table ${RUN}`,
midItem: `Mid chair ${RUN}`,
otherItem: `Wall art ${RUN}`,
dearItem: `Dear cabinet ${RUN}`
};
async function createCategory(api: APIRequestContext, name: string, parentId: number | null) {
const res = await api.post('/api/admin/categories', { data: { name, parent_id: parentId } });
expect(res.status()).toBe(201);
return (await res.json()).id as number;
}
async function createTag(api: APIRequestContext, name: string) {
const res = await api.post('/api/admin/tags', { data: { name } });
expect(res.status()).toBe(201);
return (await res.json()).id as number;
}
async function createItem(
api: APIRequestContext,
name: string,
price: string,
categoryId: number | null,
tags: string[]
) {
const res = await api.post('/api/admin/items', {
multipart: {
name,
description: '',
price,
category_id: categoryId === null ? '' : String(categoryId),
tags: JSON.stringify(tags)
}
});
expect(res.ok()).toBeTruthy();
}
test.beforeAll(async ({ playwright }) => {
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' });
// A worker can be handed tests from this file in more than one batch, which
// re-runs beforeAll against the module-cached suffix. Seeding twice would
// collide on the category names and duplicate every item, so re-entry is a
// no-op once the fixtures are in place.
const alreadySeeded = ((await (await api.get('/api/admin/categories')).json()) as { name: string }[])
.some((category) => category.name === NAMES.furniture);
if (alreadySeeded) {
await api.dispose();
return;
}
const furniture = await createCategory(api, NAMES.furniture, null);
const tables = await createCategory(api, NAMES.tables, furniture);
const decor = await createCategory(api, NAMES.decor, null);
await createTag(api, NAMES.vintage);
await createTag(api, NAMES.oak);
// Filed one level below the category the tests select, to prove descendant
// matching rather than an exact-node match.
await createItem(api, NAMES.deepItem, '340', tables, [NAMES.vintage, NAMES.oak]);
await createItem(api, NAMES.midItem, '120', furniture, [NAMES.vintage]);
await createItem(api, NAMES.otherItem, '90', decor, [NAMES.vintage, NAMES.oak]);
await createItem(api, NAMES.dearItem, '5000', tables, [NAMES.vintage, NAMES.oak]);
await api.dispose();
});
function card(page: import('@playwright/test').Page, name: string) {
return page.getByRole('heading', { name });
}
test.describe('Storefront filters', () => {
test('filters by category, including everything filed beneath it', async ({ page }) => {
await page.goto('/');
await expect(card(page, NAMES.otherItem)).toBeVisible();
await page.getByRole('button', { name: /Filters/ }).click();
await page.getByRole('treeitem', { name: NAMES.furniture }).click();
// Both the item filed directly in Furniture and the one nested under
// Furniture > Tables must survive.
await expect(card(page, NAMES.midItem)).toBeVisible();
await expect(card(page, NAMES.deepItem)).toBeVisible();
await expect(card(page, NAMES.otherItem)).toBeHidden();
});
test('a nested category is reachable in the drawer', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /Filters/ }).click();
// The tree loads after the drawer mounts, so anything below the roots is
// only reachable if expansion tracks the loaded data rather than the state
// at mount time.
await page.getByRole('treeitem', { name: NAMES.tables }).click();
await expect(card(page, NAMES.deepItem)).toBeVisible();
await expect(card(page, NAMES.midItem)).toBeHidden();
});
test('requires every selected tag rather than any of them', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /Filters/ }).click();
await page.getByRole('button', { name: NAMES.vintage }).click();
await expect(card(page, NAMES.midItem)).toBeVisible();
await page.getByRole('button', { name: NAMES.oak }).click();
// midItem carries only `vintage`, so adding `oak` must drop it.
await expect(card(page, NAMES.midItem)).toBeHidden();
await expect(card(page, NAMES.deepItem)).toBeVisible();
});
test('filters by price range', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /Filters/ }).click();
await page.getByLabel('Minimum price').fill('200');
await page.getByLabel('Maximum price').fill('1000');
await expect(card(page, NAMES.deepItem)).toBeVisible();
await expect(card(page, NAMES.midItem)).toBeHidden();
await expect(card(page, NAMES.dearItem)).toBeHidden();
});
test('removing a chip widens the results again', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /Filters/ }).click();
await page.getByRole('treeitem', { name: NAMES.decor }).click();
await page.getByRole('button', { name: 'Close' }).click();
await expect(card(page, NAMES.deepItem)).toBeHidden();
await page.getByRole('button', { name: `Remove filter ${NAMES.decor}` }).click();
await expect(card(page, NAMES.deepItem)).toBeVisible();
});
test('clear all removes every active filter', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /Filters/ }).click();
await page.getByRole('treeitem', { name: NAMES.decor }).click();
await page.getByRole('button', { name: NAMES.vintage }).click();
await page.getByRole('button', { name: 'Close' }).click();
// Scoped to the chip row: the drawer carries a "Clear all" of its own.
await page
.getByRole('group', { name: 'Active filters' })
.getByRole('button', { name: 'Clear all' })
.click();
await expect(card(page, NAMES.deepItem)).toBeVisible();
await expect(card(page, NAMES.otherItem)).toBeVisible();
await expect(page).toHaveURL(/\/$/);
});
test('a filtered view survives a reload', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /Filters/ }).click();
await page.getByRole('treeitem', { name: NAMES.furniture }).click();
await page.getByRole('button', { name: 'Close' }).click();
await expect(page).toHaveURL(/category=\d+/);
await page.reload();
await expect(card(page, NAMES.deepItem)).toBeVisible();
await expect(card(page, NAMES.otherItem)).toBeHidden();
await expect(page.getByRole('button', { name: `Remove filter ${NAMES.furniture}` })).toBeVisible();
});
test('shows an item\'s tags on its card', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /Filters/ }).click();
await page.getByRole('treeitem', { name: NAMES.decor }).click();
await page.getByRole('button', { name: 'Close' }).click();
const wallArt = page.locator('.item-card').filter({ hasText: NAMES.otherItem });
await expect(wallArt.getByText(NAMES.vintage)).toBeVisible();
await expect(wallArt.getByText(NAMES.oak)).toBeVisible();
});
});