From 77e58c0b92fbc360349bf26c70ceaca0b3adce8d Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Mon, 17 Aug 2026 11:07:44 -0500 Subject: [PATCH] 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) --- frontend/playwright.config.ts | 7 +- frontend/src/admin/Admin.tsx | 73 +++++++----- frontend/src/admin/CategoryTreeSelect.tsx | 107 ++++++++++++++++++ frontend/src/api.ts | 34 +++++- frontend/src/main.tsx | 26 ++++- .../tests/e2e/admin-inline-category.spec.ts | 77 +++++++++++++ .../tests/e2e/admin-save-failures.spec.ts | 75 ++++++++++++ 7 files changed, 361 insertions(+), 38 deletions(-) create mode 100644 frontend/src/admin/CategoryTreeSelect.tsx create mode 100644 frontend/tests/e2e/admin-inline-category.spec.ts create mode 100644 frontend/tests/e2e/admin-save-failures.spec.ts diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 6ac5da9..f37abfa 100755 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -7,7 +7,12 @@ export default defineConfig({ reporter: [['list']], use: { baseURL: 'http://localhost:5173', - trace: 'on-first-retry' + trace: 'on-first-retry', + // The app turns off antd's transitions under this preference. Animated + // popups never settle long enough for Playwright's stability check when + // the machine is loaded, which showed up as clicks timing out on a button + // that was plainly visible and enabled. + reducedMotion: 'reduce' }, webServer: { command: 'npm run dev', diff --git a/frontend/src/admin/Admin.tsx b/frontend/src/admin/Admin.tsx index c0cb761..57dcb9e 100755 --- a/frontend/src/admin/Admin.tsx +++ b/frontend/src/admin/Admin.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { Layout, Table, Button, Form, Input, InputNumber, Upload, Modal, Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs, - TreeSelect, Select + Select } from 'antd'; import { UploadOutlined, DeleteOutlined } from '@ant-design/icons'; import type { UploadFile } from 'antd/es/upload/interface'; @@ -14,26 +14,12 @@ import { fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable, fetchAdminCategories, fetchAdminTags } from '../api'; -import { buildCategoryTree, CategoryNode } from '../filters'; import { useThemeMode } from '../theme/ThemeContext'; import Customers from './Customers'; import Settings from './Settings'; import Categories from './Categories'; import Tags from './Tags'; - -interface CategoryTreeOption { - value: number; - title: string; - children?: CategoryTreeOption[]; -} - -function toCategoryTreeData(nodes: CategoryNode[]): CategoryTreeOption[] { - return nodes.map(node => ({ - value: node.id, - title: node.name, - children: node.children.length ? toCategoryTreeData(node.children) : undefined - })); -} +import CategoryTreeSelect from './CategoryTreeSelect'; const { Header, Content } = Layout; const { Title } = Typography; @@ -47,6 +33,7 @@ function Inventory() { const [description, setDescription] = useState(''); const [categories, setCategories] = useState([]); const [tags, setTags] = useState([]); + const [saving, setSaving] = useState(false); const { mode } = useThemeMode(); const load = () => fetchAdminItems().then(setItems); @@ -94,21 +81,55 @@ function Inventory() { fd.append('category_id', values.category_id == null ? '' : String(values.category_id)); fd.append('tags', JSON.stringify(values.tags ?? [])); fileList.forEach(f => { if (f.originFileObj) fd.append('images', f.originFileObj as File); }); - await saveItem(editingItem?.id ?? null, fd); + + // Only report success, close the form, and discard the entered values once + // the server has actually accepted the write. + setSaving(true); + try { + await saveItem(editingItem?.id ?? null, fd); + } catch (err) { + message.error(`Couldn't save item — ${(err as Error).message}`); + return; + } finally { + setSaving(false); + } + message.success(editingItem ? 'Item updated' : 'Item added'); setModalOpen(false); load(); loadOptions(); } + // Status changes silently did nothing on failure — the row simply stayed put + // with no indication why. + async function handleStatusChange(action: (id: number) => Promise, id: number, label: string) { + try { + await action(id); + } catch (err) { + message.error(`Couldn't ${label} — ${(err as Error).message}`); + return; + } + load(); + } + async function handleDelete(id: number) { - await deleteItem(id); + try { + await deleteItem(id); + } catch (err) { + message.error(`Couldn't delete item — ${(err as Error).message}`); + return; + } message.success('Item deleted'); load(); } async function handleDeleteImage(itemId: number, imageId: number) { - await deleteItemImage(itemId, imageId); + try { + await deleteItemImage(itemId, imageId); + } catch (err) { + message.error(`Couldn't remove image — ${(err as Error).message}`); + return; + } message.success('Image removed'); load(); setEditingItem(prev => prev && prev.id === itemId @@ -159,8 +180,8 @@ function Inventory() { {item.status !== 'sold' - ? - : } + ? + : } ) } @@ -174,7 +195,7 @@ function Inventory() { - setModalOpen(false)} destroyOnClose width={720}> + setModalOpen(false)} destroyOnHidden width={720}>
@@ -187,13 +208,7 @@ function Inventory() { - + ({ + value: node.id, + title: node.name, + children: node.children.length ? toTreeData(node.children) : undefined + })); +} + +interface Props { + // Supplied by antd's Form.Item. `id` has to be forwarded or the field loses + // its association with the rendered