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