fix(admin): confirm writes succeeded and allow inline category creation (#23)

Reported from testing: the item form said "Item added" for a save that
never happened.

saveItem and the other admin calls returned res.json() without checking
res.ok, so a 4xx/5xx resolved normally and every caller reported success
for a write the server had rejected. That is worse than failing outright,
because nothing prompts the user to look for the missing row. All admin
calls now throw on a non-OK response, and the handlers report the error,
keep the form open so entered values survive, and only claim success once
the server has accepted the write. Mark sold/available previously did
nothing visible on failure at all.

Categories can now be created from the item form, as tags already could.
Previously a category that did not exist yet meant abandoning a
half-filled form for the Categories tab. New categories are created at
the top level; nesting stays in the Categories tab.

The control lives in its own component: inline, every keystroke
re-rendered the whole Inventory component and rebuilt the category tree,
which visibly jittered the open popup. It sits above the tree rather than
below it, where a long list both hid it and made its position depend on
the list's measured height. The tree no longer expands everything on
open, which does not scale past a screenful; it has search instead.

The app now honours prefers-reduced-motion by disabling antd transitions,
and the e2e suite runs with that preference set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 11:07:44 -05:00
co-authored by Claude Opus 5
parent c77fdad2b9
commit 77e58c0b92
7 changed files with 361 additions and 38 deletions
@@ -0,0 +1,77 @@
import { test, expect } from '@playwright/test';
const suffix = () => `i${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
// Opening the form kicks off a categories fetch. Interacting with the category
// field before it lands means the tree re-renders under the cursor, so wait for
// the data rather than racing it.
async function openItemForm(page: import('@playwright/test').Page) {
// Opening the form fetches both categories and tags, and each one re-renders
// the modal as it lands. Waiting for only one still leaves the second to
// reflow the popup mid-interaction.
const loaded = Promise.all([
page.waitForResponse((res) => res.url().includes('/api/admin/categories') && res.request().method() === 'GET'),
page.waitForResponse((res) => res.url().includes('/api/admin/tags') && res.request().method() === 'GET')
]);
await page.getByRole('button', { name: 'Add Item' }).click();
await loaded;
}
test.describe('Inline category creation from the item form', () => {
test('creates a category without leaving the item form and assigns it', async ({ page }) => {
const RUN = suffix();
const categoryName = `Inline ${RUN}`;
const itemName = `Item ${RUN}`;
await page.goto('/admin');
await openItemForm(page);
await page.getByLabel('Name').fill(itemName);
await page.getByLabel('Price (USD)').fill('99');
await page.getByRole('dialog').getByLabel('Category', { exact: true }).click();
const nameInput = page.getByPlaceholder('New category name');
await expect(nameInput).toBeVisible();
await expect(page.getByRole('button', { name: 'Create category' })).toBeVisible();
await nameInput.fill(categoryName);
// Submitted with Enter rather than a click: the popup sits over a
// virtualized tree that keeps re-measuring, so a click target inside it is
// never geometrically stable. Enter runs the same handler as the button.
await nameInput.press('Enter');
// The new category should be selected straight away — having to hunt for it
// in the tree afterwards defeats the point of creating it inline.
await expect(page.getByRole('dialog').getByText(categoryName)).toBeVisible();
await page.getByRole('button', { name: 'OK' }).click();
await expect(page.getByText('Item added')).toBeVisible();
const items = await (await page.request.get('/api/admin/items')).json();
const saved = items.find((item: { name: string }) => item.name === itemName);
expect(saved).toBeTruthy();
expect(saved.category_name).toBe(categoryName);
});
test('reports a duplicate category name instead of silently doing nothing', async ({ page }) => {
const RUN = suffix();
const categoryName = `Dupe ${RUN}`;
const created = await page.request.post('/api/admin/categories', {
data: { name: categoryName, parent_id: null }
});
expect(created.status()).toBe(201);
await page.goto('/admin');
await openItemForm(page);
await page.getByRole('dialog').getByLabel('Category', { exact: true }).click();
const nameInput = page.getByPlaceholder('New category name');
await expect(nameInput).toBeVisible();
await expect(page.getByRole('button', { name: 'Create category' })).toBeVisible();
await nameInput.fill(categoryName);
// Submitted with Enter rather than a click: the popup sits over a
// virtualized tree that keeps re-measuring, so a click target inside it is
// never geometrically stable. Enter runs the same handler as the button.
await nameInput.press('Enter');
await expect(page.getByText(/already exists/i)).toBeVisible();
});
});
@@ -0,0 +1,75 @@
import { test, expect } from '@playwright/test';
const suffix = () => `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
test.describe('Admin save failures', () => {
test('does not claim an item was saved when the request failed', async ({ page }) => {
await page.route('**/api/admin/items', (route) => {
if (route.request().method() === 'POST') {
return route.fulfill({
status: 500,
contentType: 'application/json',
body: '{"error":"internal error"}'
});
}
return route.continue();
});
await page.goto('/admin');
await page.getByRole('button', { name: 'Add Item' }).click();
await page.getByLabel('Name').fill(`Broken ${suffix()}`);
await page.getByLabel('Price (USD)').fill('12');
await page.getByRole('button', { name: 'OK' }).click();
// Reporting success for a failed save is worse than failing loudly: the
// item is silently absent and the user has no reason to look for it.
await expect(page.getByText('Item added')).toBeHidden();
await expect(page.getByText("Couldn't save item")).toBeVisible();
// The form must stay open so the entered values aren't lost.
await expect(page.getByRole('dialog')).toBeVisible();
});
test('reports a failed delete rather than claiming success', async ({ page }) => {
// Seeded through the API so the test owns a known row rather than clicking
// whichever Delete button happens to be first in a paginated table.
const name = `Doomed ${suffix()}`;
const created = await page.request.post('/api/admin/items', {
multipart: { name, description: '', price: '10', category_id: '', tags: '[]' }
});
expect(created.ok()).toBeTruthy();
await page.route('**/api/admin/items/*', (route) => {
if (route.request().method() === 'DELETE') {
return route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"nope"}' });
}
return route.continue();
});
await page.goto('/admin');
// Items list newest-first, so the seeded row is on the first page.
const row = page.getByRole('row').filter({ hasText: name });
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Item deleted')).toBeHidden();
await expect(page.getByText("Couldn't delete item")).toBeVisible();
// The row must survive a failed delete.
await expect(row).toBeVisible();
});
test('saves an item successfully when the server accepts it', async ({ page }) => {
const name = `Good ${suffix()}`;
await page.goto('/admin');
await page.getByRole('button', { name: 'Add Item' }).click();
await page.getByLabel('Name').fill(name);
await page.getByLabel('Price (USD)').fill('34');
await page.getByRole('button', { name: 'OK' }).click();
await expect(page.getByText('Item added')).toBeVisible();
// Confirm the row actually reached the database, not just that a toast fired.
const items = await (await page.request.get('/api/admin/items')).json();
expect(items.some((item: { name: string }) => item.name === name)).toBeTruthy();
});
});