diff --git a/src/components/ProductRow.tsx b/src/components/ProductRow.tsx index 38df5ba..ec01054 100644 --- a/src/components/ProductRow.tsx +++ b/src/components/ProductRow.tsx @@ -50,16 +50,16 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow const [isEditing, setIsEditing] = useState(false); const [formState, setFormState] = useState(() => getInitialFormState(product)); const [isScannerOpen, setIsScannerOpen] = useState(false); - const [saveError, setSaveError] = useState(''); + const [fieldErrors, setFieldErrors] = useState<{ name?: string; barcode?: string }>({}); useEffect(() => { setFormState(getInitialFormState(product)); - setSaveError(''); + setFieldErrors({}); }, [product]); const handleChange = (field: keyof ProductFormState) => (event: ChangeEvent) => { setFormState((prev) => ({ ...prev, [field]: event.target.value })); - setSaveError(''); + setFieldErrors((prev) => ({ ...prev, [field]: undefined })); }; const handleSave = async () => { @@ -71,10 +71,14 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow barcode: formState.barcode || undefined, }); setIsEditing(false); - setSaveError(''); + setFieldErrors({}); } catch (error) { + if (error instanceof Error && error.name === 'DuplicateNameError') { + setFieldErrors({ name: 'A product with this name already exists.' }); + return; + } if (error instanceof Error && error.name === 'DuplicateBarcodeError') { - setSaveError('This barcode is already assigned to another product.'); + setFieldErrors({ barcode: 'This barcode is already assigned to another product.' }); return; } throw error; @@ -84,7 +88,7 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow const handleCancel = () => { setIsEditing(false); setFormState(getInitialFormState(product)); - setSaveError(''); + setFieldErrors({}); }; return ( @@ -92,7 +96,14 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow {isEditing ? ( - + { setFormState((prev) => ({ ...prev, barcode: '' })); - setSaveError(''); + setFieldErrors((prev) => ({ ...prev, barcode: undefined })); }} > Clear diff --git a/src/screens/ManageProductsScreen.test.tsx b/src/screens/ManageProductsScreen.test.tsx index 575dde9..1e60772 100644 --- a/src/screens/ManageProductsScreen.test.tsx +++ b/src/screens/ManageProductsScreen.test.tsx @@ -165,6 +165,35 @@ describe('ManageProductsScreen barcode lookup', () => { expect(mockDb.products.add).not.toHaveBeenCalled(); }); + it('prevents adding a product with a duplicate name (case-insensitive)', async () => { + mockUseProducts.mockReturnValue([ + { + id: 'prod-1', + name: 'Existing Product', + category: 'Snacks', + barcode: undefined, + unit_type: 'unit', + bulk_name: 'pack', + archived: false, + created_at: 0, + updated_at: 0, + }, + ]); + + const user = userEvent.setup(); + render( + + + , + ); + + await user.type(screen.getByLabelText(/name/i), 'existing product'); + await user.click(screen.getByRole('button', { name: /save product/i })); + + expect(await screen.findByText(/product with this name already exists/i)).toBeVisible(); + expect(mockDb.products.add).not.toHaveBeenCalled(); + }); + it('informs the user when barcode lookup happens offline', async () => { const originalNavigator = navigator; Object.defineProperty(globalThis, 'navigator', { @@ -237,6 +266,55 @@ describe('ManageProductsScreen barcode lookup', () => { }); expect(mockDb.products.update).not.toHaveBeenCalled(); }); + + it('prevents updating a product to use an existing name', async () => { + mockUseProducts.mockReturnValue([ + { + id: 'prod-1', + name: 'Existing Product', + category: 'Snacks', + barcode: '123456', + unit_type: 'unit', + bulk_name: 'pack', + archived: false, + created_at: 0, + updated_at: 0, + }, + { + id: 'prod-2', + name: 'Another Product', + category: 'Snacks', + barcode: '654321', + unit_type: 'unit', + bulk_name: 'pack', + archived: false, + created_at: 0, + updated_at: 0, + }, + ]); + + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(screen.getByLabelText(/edit another product/i)); + const nameField = screen + .getAllByLabelText(/name/i) + .find((input) => (input as HTMLInputElement).value === 'Another Product'); + expect(nameField).toBeDefined(); + fireEvent.change(nameField as Element, { target: { value: 'Existing Product' } }); + expect(nameField).toHaveValue('Existing Product'); + await user.click(screen.getByLabelText(/save product/i)); + + await waitFor(() => { + expect(nameField).toHaveAccessibleDescription('A product with this name already exists.'); + expect(nameField).toHaveAttribute('aria-invalid', 'true'); + }); + expect(mockDb.products.update).not.toHaveBeenCalled(); + }); }); describe('ManageProductsScreen auto-adding products to pick lists', () => { diff --git a/src/screens/ManageProductsScreen.tsx b/src/screens/ManageProductsScreen.tsx index 926389f..49e6976 100644 --- a/src/screens/ManageProductsScreen.tsx +++ b/src/screens/ManageProductsScreen.tsx @@ -33,6 +33,7 @@ export const ManageProductsScreen = () => { const [category, setCategory] = useState(''); const [barcode, setBarcode] = useState(''); const [barcodeError, setBarcodeError] = useState(''); + const [nameError, setNameError] = useState(''); const [scannerOpen, setScannerOpen] = useState(false); const [lookupStatus, setLookupStatus] = useState<'idle' | 'loading' | 'found' | 'notfound' | 'offline'>( 'idle', @@ -78,6 +79,19 @@ export const ManageProductsScreen = () => { [products], ); + const findNameConflict = useCallback( + (value?: string, productId?: string) => { + if (!value) return undefined; + + const normalizedValue = value.trim().toLowerCase(); + + return products.find( + (product) => product.id !== productId && product.name.trim().toLowerCase() === normalizedValue, + ); + }, + [products], + ); + const assertUniqueBarcode = useCallback( async (value?: string, productId?: string) => { if (!value) return; @@ -94,6 +108,22 @@ export const ManageProductsScreen = () => { [db.products, findBarcodeConflict], ); + const assertUniqueName = useCallback( + async (value: string, productId?: string) => { + const normalized = value.trim().toLowerCase(); + if (!normalized) return; + + const conflict = findNameConflict(value, productId); + + if (conflict) { + const error = new Error('A product with this name already exists.'); + error.name = 'DuplicateNameError'; + throw error; + } + }, + [findNameConflict], + ); + const addProductToAutoLists = useCallback( async (product: Product, timestamp: number) => { const pickLists = await db.pickLists.toArray(); @@ -167,8 +197,13 @@ export const ManageProductsScreen = () => { if (!name || !category) return; try { + await assertUniqueName(name); await assertUniqueBarcode(barcode || undefined); } catch (error) { + if (error instanceof Error && error.name === 'DuplicateNameError') { + setNameError(error.message); + return; + } if (error instanceof Error && error.name === 'DuplicateBarcodeError') { setBarcodeError(error.message); return; @@ -195,6 +230,7 @@ export const ManageProductsScreen = () => { }); setName(''); setBarcode(''); + setNameError(''); setFeedback({ text: 'Product added.', severity: 'success' }); }; @@ -206,6 +242,7 @@ export const ManageProductsScreen = () => { barcode?: string; }, ) => { + await assertUniqueName(updates.name, productId); await assertUniqueBarcode(updates.barcode, productId); await db.products.update(productId, { @@ -251,12 +288,23 @@ export const ManageProductsScreen = () => { setName(event.target.value)} + onChange={(event) => { + setName(event.target.value); + setNameError(''); + }} + error={Boolean(nameError)} + helperText={nameError || undefined} InputProps={ name ? { endAdornment: ( - ),