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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user