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:
@@ -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<string>('');
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [tags, setTags] = useState<TagRecord[]>([]);
|
||||
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<Item>, 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() {
|
||||
<Button size="small" onClick={() => openEdit(item)}>Edit</Button>
|
||||
<Button size="small" danger onClick={() => handleDelete(item.id)}>Delete</Button>
|
||||
{item.status !== 'sold'
|
||||
? <Button size="small" onClick={() => markSold(item.id).then(load)}>Mark Sold</Button>
|
||||
: <Button size="small" onClick={() => markAvailable(item.id).then(load)}>Mark Available</Button>}
|
||||
? <Button size="small" onClick={() => handleStatusChange(markSold, item.id, 'mark sold')}>Mark Sold</Button>
|
||||
: <Button size="small" onClick={() => handleStatusChange(markAvailable, item.id, 'mark available')}>Mark Available</Button>}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -174,7 +195,7 @@ function Inventory() {
|
||||
</div>
|
||||
<Table rowKey="id" dataSource={items} columns={columns} scroll={{ x: true }} />
|
||||
|
||||
<Modal title={editingItem ? 'Edit Item' : 'Add Item'} open={modalOpen} onOk={handleOk} onCancel={() => setModalOpen(false)} destroyOnClose width={720}>
|
||||
<Modal title={editingItem ? 'Edit Item' : 'Add Item'} open={modalOpen} onOk={handleOk} confirmLoading={saving} onCancel={() => setModalOpen(false)} destroyOnHidden width={720}>
|
||||
<div data-color-mode={mode}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="Name" rules={[{ required: true }]}>
|
||||
@@ -187,13 +208,7 @@ function Inventory() {
|
||||
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="category_id" label="Category">
|
||||
<TreeSelect
|
||||
allowClear
|
||||
placeholder="Uncategorized"
|
||||
treeDefaultExpandAll
|
||||
treeData={toCategoryTreeData(buildCategoryTree(categories))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<CategoryTreeSelect categories={categories} onCategoriesChanged={setCategories} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="tags"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import TreeSelect from 'antd/lib/tree-select';
|
||||
import Input from 'antd/lib/input';
|
||||
import Button from 'antd/lib/button';
|
||||
import Divider from 'antd/lib/divider';
|
||||
import message from 'antd/lib/message';
|
||||
import { Category, createCategory, fetchAdminCategories } from '../api';
|
||||
import { buildCategoryTree, CategoryNode } from '../filters';
|
||||
|
||||
interface CategoryTreeOption {
|
||||
value: number;
|
||||
title: string;
|
||||
children?: CategoryTreeOption[];
|
||||
}
|
||||
|
||||
function toTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
|
||||
return nodes.map((node) => ({
|
||||
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 <label>, which breaks both screen readers
|
||||
// and any lookup by label.
|
||||
value?: number;
|
||||
onChange?: (value: number | undefined) => void;
|
||||
id?: string;
|
||||
categories: Category[];
|
||||
onCategoriesChanged: (categories: Category[]) => void;
|
||||
}
|
||||
|
||||
// Pulled out of the item form so that typing a new category name re-renders
|
||||
// only this control. Left inline, every keystroke re-rendered the whole
|
||||
// Inventory component and rebuilt the tree data, which visibly jittered the
|
||||
// open popup and moved the buttons under the pointer.
|
||||
export default function CategoryTreeSelect({ value, onChange, id, categories, onCategoriesChanged }: Props) {
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const treeData = useMemo(() => toTreeData(buildCategoryTree(categories)), [categories]);
|
||||
|
||||
// Tags can be invented from the item form, so categories should be too —
|
||||
// otherwise adding an item in a new category means abandoning a half-filled
|
||||
// form. New categories land at the top level; nesting is done in the
|
||||
// Categories tab, keeping this control to a single decision.
|
||||
async function handleCreate() {
|
||||
const name = newCategoryName.trim();
|
||||
if (!name) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await createCategory(name, null);
|
||||
onCategoriesChanged(await fetchAdminCategories());
|
||||
onChange?.(created.id);
|
||||
setNewCategoryName('');
|
||||
message.success(`Category "${created.name}" added`);
|
||||
} catch (err) {
|
||||
message.error(`Couldn't create category — ${(err as Error).message}`);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeSelect
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder="Uncategorized"
|
||||
// Deliberately not treeDefaultExpandAll: expanding a large tree on open
|
||||
// makes the popup reflow while it measures, and buries the create field
|
||||
// below every row. Search is the better affordance past a screenful.
|
||||
treeNodeFilterProp="title"
|
||||
listHeight={256}
|
||||
treeData={treeData}
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
style={{ width: '100%' }}
|
||||
popupRender={(menu) => (
|
||||
<>
|
||||
{/* Above the tree, not below it. Under a long list the control is
|
||||
both invisible without scrolling and positioned by the list's
|
||||
measured height, so it shifts as the virtualized rows settle. */}
|
||||
<div style={{ display: 'flex', gap: 8, padding: '8px 8px 0' }}>
|
||||
<Input
|
||||
placeholder="New category name"
|
||||
value={newCategoryName}
|
||||
onChange={(event) => setNewCategoryName(event.target.value)}
|
||||
// Without this the tree steals the keystrokes for its own
|
||||
// type-ahead and arrow-key navigation.
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
onPressEnter={handleCreate}
|
||||
/>
|
||||
<Button type="primary" loading={creating} onClick={handleCreate}>
|
||||
Create category
|
||||
</Button>
|
||||
</div>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
{menu}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user