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