feat(ui): storefront filters and admin category/tag management (#23)
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user