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
+107
View File
@@ -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}
</>
)}
/>
);
}