fix(admin): theme, American English, and inventory/reservation tooling (#27)
Seven reported items, of which the first four had two root causes. The active tab was invisible in dark mode because colorPrimary was hardcoded to #1a1a1a in both themes. The accent now inverts with the theme, and colorTextLightSolid inverts with it, or a near-white accent would get antd's default white label and disappear. The Category tab, Tag tab, and item-form category selector ignored the theme entirely. antd declares main: lib/index.js and module: es/index.js, so importing from 'antd' resolves to the ES build while 'antd/lib/...' loads the CommonJS one — two copies, two React contexts, and no ConfigProvider for anything deep-imported. Switching those files to antd/es/* keeps the deep-import convention and shares the instance. This was introduced by my own use of the lib path; es is correct under Vite. Two storefront components had the same latent bug. "Colour" is now "Color". The Customers tab shows how many items each customer is holding, as a link opening the item list with a Release button. Release mirrors the customer's own cart removal — drop the cart row, return the item to available, guarded on 'reserved' so it can never resurrect a sold item — and deliberately sends no email about an action the customer did not take. The count is a subquery rather than another join, which would have multiplied rows and inflated order_count and total_spent_cents. The Inventory tab filters by category, tags, price, and status, reusing the storefront's parser and query builder so the two cannot drift. Reserved is one option in a Status filter rather than a standalone toggle. Also fixes two defects the screenshots exposed: the reserved-count link bubbled to the row handler and opened the customer drawer behind the dialog, and .admin-category-node had no CSS at all, so the tree node name, item count, and actions ran together as one string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
const RUN = `v${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
const NAMES = {
|
||||
category: `Filterable ${RUN}`,
|
||||
tag: `filt-${RUN}`,
|
||||
cheap: `Cheap item ${RUN}`,
|
||||
mid: `Mid item ${RUN}`,
|
||||
dear: `Dear item ${RUN}`
|
||||
};
|
||||
|
||||
test.beforeAll(async ({ playwright }) => {
|
||||
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' });
|
||||
|
||||
const categoryId = (await (await api.post('/api/admin/categories', {
|
||||
data: { name: NAMES.category, parent_id: null }
|
||||
})).json()).id;
|
||||
await api.post('/api/admin/tags', { data: { name: NAMES.tag } });
|
||||
|
||||
const item = (name: string, price: string, inCategory: boolean) =>
|
||||
api.post('/api/admin/items', {
|
||||
multipart: {
|
||||
name,
|
||||
description: '',
|
||||
price,
|
||||
category_id: inCategory ? String(categoryId) : '',
|
||||
tags: JSON.stringify(inCategory ? [NAMES.tag] : [])
|
||||
}
|
||||
});
|
||||
|
||||
await item(NAMES.cheap, '50', true);
|
||||
await item(NAMES.mid, '150', true);
|
||||
await item(NAMES.dear, '900', true);
|
||||
|
||||
await api.dispose();
|
||||
});
|
||||
|
||||
const row = (page: Page, name: string) => page.getByRole('row').filter({ hasText: name });
|
||||
|
||||
// The Inventory table paginates and other specs create items concurrently, so
|
||||
// an unfiltered page 1 is not a reliable place to look for a fixture. Every
|
||||
// assertion below therefore runs against a category filter that narrows the
|
||||
// table to this spec's own items.
|
||||
async function filterToOwnCategory(page: Page) {
|
||||
const category = page.getByRole('combobox', { name: 'Filter by category' });
|
||||
await category.click();
|
||||
await category.fill(NAMES.category);
|
||||
await page.getByTitle(NAMES.category, { exact: true }).click();
|
||||
await expect(row(page, NAMES.cheap)).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Admin inventory filters', () => {
|
||||
test('filters by category', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await filterToOwnCategory(page);
|
||||
|
||||
// All three fixtures share the category, and nothing else does.
|
||||
await expect(row(page, NAMES.cheap)).toBeVisible();
|
||||
await expect(row(page, NAMES.mid)).toBeVisible();
|
||||
await expect(row(page, NAMES.dear)).toBeVisible();
|
||||
});
|
||||
|
||||
test('filters by price range', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await filterToOwnCategory(page);
|
||||
|
||||
await page.getByLabel('Minimum price').fill('100');
|
||||
await page.getByLabel('Maximum price').fill('500');
|
||||
|
||||
await expect(row(page, NAMES.mid)).toBeVisible();
|
||||
await expect(row(page, NAMES.cheap)).toHaveCount(0);
|
||||
await expect(row(page, NAMES.dear)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('filters by status, which is how Reserved is reached', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await filterToOwnCategory(page);
|
||||
|
||||
await page.getByRole('combobox', { name: 'Filter by status' }).click();
|
||||
await page.getByTitle('Sold', { exact: true }).click();
|
||||
|
||||
// Every fixture is available, so a Sold filter must exclude them all.
|
||||
await expect(row(page, NAMES.cheap)).toHaveCount(0);
|
||||
await expect(row(page, NAMES.mid)).toHaveCount(0);
|
||||
await expect(row(page, NAMES.dear)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('combines filters, and clearing restores them', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await filterToOwnCategory(page);
|
||||
await page.getByLabel('Minimum price').fill('800');
|
||||
await expect(row(page, NAMES.cheap)).toHaveCount(0);
|
||||
await expect(row(page, NAMES.dear)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Clear filters' }).click();
|
||||
|
||||
// Asserting on the controls rather than on the rows: with the filters gone
|
||||
// the table is the whole paginated catalogue again, so a given fixture is
|
||||
// not reliably on the first page.
|
||||
await expect(page.getByRole('button', { name: 'Clear filters' })).toHaveCount(0);
|
||||
await expect(page.getByLabel('Minimum price')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const suffix = () => `r${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
// Reserves an item by registering a customer and adding it to their cart, which
|
||||
// is the only way an item legitimately reaches 'reserved'.
|
||||
async function reserveItem(page: import('@playwright/test').Page, itemName: string) {
|
||||
const email = `reserve-${suffix()}@example.com`;
|
||||
|
||||
const created = await page.request.post('/api/admin/items', {
|
||||
multipart: { name: itemName, description: '', price: '75', category_id: '', tags: '[]' }
|
||||
});
|
||||
expect(created.ok()).toBeTruthy();
|
||||
const itemId = (await created.json()).id as number;
|
||||
|
||||
await page.goto('/register');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||
await page.getByLabel('Password').fill('supersecret123');
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
await expect(page).toHaveURL(/\/account/);
|
||||
|
||||
const added = await page.request.post(`/api/cart/items/${itemId}`);
|
||||
expect(added.status()).toBe(201);
|
||||
|
||||
return { email, itemId };
|
||||
}
|
||||
|
||||
test.describe('Admin reserved items', () => {
|
||||
test('shows a reserved count that opens the held items', async ({ page }) => {
|
||||
const itemName = `Held ${suffix()}`;
|
||||
const { email } = await reserveItem(page, itemName);
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('tab', { name: 'Customers' }).click();
|
||||
|
||||
const row = page.getByRole('row').filter({ hasText: email });
|
||||
await expect(row).toBeVisible();
|
||||
await row.getByRole('button', { name: /item/ }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: /Items reserved by/ });
|
||||
await expect(dialog.getByText(itemName)).toBeVisible();
|
||||
});
|
||||
|
||||
test('releasing an item returns it to the storefront as available', async ({ page }) => {
|
||||
const itemName = `Freed ${suffix()}`;
|
||||
const { email, itemId } = await reserveItem(page, itemName);
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('tab', { name: 'Customers' }).click();
|
||||
const row = page.getByRole('row').filter({ hasText: email });
|
||||
await row.getByRole('button', { name: /item/ }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: /Items reserved by/ });
|
||||
await dialog.getByRole('button', { name: 'Release' }).click();
|
||||
await expect(page.getByText(`Released "${itemName}"`)).toBeVisible();
|
||||
|
||||
// The point of releasing is that the item becomes purchasable again.
|
||||
const item = await (await page.request.get(`/api/items/${itemId}`)).json();
|
||||
expect(item.status).toBe('available');
|
||||
});
|
||||
|
||||
test('the count drops once the item is released', async ({ page }) => {
|
||||
const itemName = `Recount ${suffix()}`;
|
||||
const { email } = await reserveItem(page, itemName);
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('tab', { name: 'Customers' }).click();
|
||||
const row = page.getByRole('row').filter({ hasText: email });
|
||||
await row.getByRole('button', { name: /item/ }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: /Items reserved by/ });
|
||||
await dialog.getByRole('button', { name: 'Release' }).click();
|
||||
await expect(dialog.getByText("This customer isn't holding any items")).toBeVisible();
|
||||
|
||||
// The row behind the dialog must agree with the dialog it opened.
|
||||
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(row.getByRole('button', { name: /item/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a customer holding nothing shows no link to click', async ({ page }) => {
|
||||
const email = `idle-${suffix()}@example.com`;
|
||||
await page.goto('/register');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||
await page.getByLabel('Password').fill('supersecret123');
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
await expect(page).toHaveURL(/\/account/);
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('tab', { name: 'Customers' }).click();
|
||||
const row = page.getByRole('row').filter({ hasText: email });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.getByRole('button', { name: /item/ })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
// Relative luminance per WCAG, used to tell "light" from "dark" without
|
||||
// asserting exact hex values, which would break on any palette tweak.
|
||||
function luminance(rgb: string): number {
|
||||
const [r, g, b] = (rgb.match(/\d+(\.\d+)?/g) ?? ['0', '0', '0']).slice(0, 3).map(Number);
|
||||
const channel = (c: number) => {
|
||||
const s = c / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
||||
}
|
||||
|
||||
const suffix = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
// The Categories and Tags tabs render an empty state when there is nothing to
|
||||
// show, and the shared dev database gets truncated by the backend integration
|
||||
// suite. Seed what each test needs rather than depending on what happens to be
|
||||
// there.
|
||||
async function seed(page: Page) {
|
||||
const run = suffix();
|
||||
await page.request.post('/api/admin/categories', { data: { name: `Theme ${run}`, parent_id: null } });
|
||||
await page.request.post('/api/admin/tags', { data: { name: `theme-${run}` } });
|
||||
}
|
||||
|
||||
async function switchToDark(page: Page) {
|
||||
await page.goto('/admin');
|
||||
const toggle = page.getByRole('switch');
|
||||
if ((await toggle.getAttribute('aria-checked')) !== 'true') {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(page.locator('body')).toHaveAttribute('data-theme', 'dark');
|
||||
}
|
||||
|
||||
test.describe('Admin dark mode', () => {
|
||||
test('the active tab label is readable against the dark background', async ({ page }) => {
|
||||
await switchToDark(page);
|
||||
|
||||
const label = page.locator('.ant-tabs-tab-active .ant-tabs-tab-btn').first();
|
||||
|
||||
// The bug: colorPrimary stayed #1a1a1a in dark mode, so the active tab was
|
||||
// near-black text on a near-black background.
|
||||
await expect
|
||||
.poll(async () => luminance(await label.evaluate((el) => getComputedStyle(el).color)))
|
||||
.toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
test('the Categories tab follows the dark theme', async ({ page }) => {
|
||||
await seed(page);
|
||||
await switchToDark(page);
|
||||
await page.getByRole('tab', { name: 'Categories' }).click();
|
||||
|
||||
const tree = page.locator('.ant-tabs-tabpane-active .ant-tree').first();
|
||||
await expect(tree).toBeVisible();
|
||||
const bg = await tree.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
|
||||
// The bug: deep imports from antd/lib loaded a second copy of antd that
|
||||
// never saw ConfigProvider, so this rendered pure white in dark mode.
|
||||
expect(luminance(bg)).toBeLessThan(0.5);
|
||||
});
|
||||
|
||||
test('the Tags tab follows the dark theme', async ({ page }) => {
|
||||
await seed(page);
|
||||
await switchToDark(page);
|
||||
await page.getByRole('tab', { name: 'Tags' }).click();
|
||||
|
||||
const table = page.locator('.ant-tabs-tabpane-active .ant-table').first();
|
||||
await expect(table).toBeVisible();
|
||||
const bg = await table.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(luminance(bg)).toBeLessThan(0.5);
|
||||
});
|
||||
|
||||
test('the item form category selector follows the dark theme', async ({ page }) => {
|
||||
await switchToDark(page);
|
||||
await page.getByRole('button', { name: 'Add Item' }).click();
|
||||
await page.getByRole('dialog').getByLabel('Category', { exact: true }).click();
|
||||
|
||||
const popup = page.locator('.ant-select-dropdown').first();
|
||||
await expect(popup).toBeVisible();
|
||||
const bg = await popup.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(luminance(bg)).toBeLessThan(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Admin copy', () => {
|
||||
test('uses American English spelling for color', async ({ page }) => {
|
||||
await seed(page);
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('tab', { name: 'Tags' }).click();
|
||||
|
||||
await expect(page.getByRole('columnheader', { name: 'Color' })).toBeVisible();
|
||||
await expect(page.getByText('Colour')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user