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();
});
});