diff --git a/src/components/ProductRow.tsx b/src/components/ProductRow.tsx index c93d2c7..38df5ba 100644 --- a/src/components/ProductRow.tsx +++ b/src/components/ProductRow.tsx @@ -50,28 +50,41 @@ 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(''); useEffect(() => { setFormState(getInitialFormState(product)); + setSaveError(''); }, [product]); const handleChange = (field: keyof ProductFormState) => (event: ChangeEvent) => { setFormState((prev) => ({ ...prev, [field]: event.target.value })); + setSaveError(''); }; const handleSave = async () => { if (!formState.name) return; - await onSave(product.id, { - name: formState.name, - category: formState.category, - barcode: formState.barcode || undefined, - }); - setIsEditing(false); + try { + await onSave(product.id, { + name: formState.name, + category: formState.category, + barcode: formState.barcode || undefined, + }); + setIsEditing(false); + setSaveError(''); + } catch (error) { + if (error instanceof Error && error.name === 'DuplicateBarcodeError') { + setSaveError('This barcode is already assigned to another product.'); + return; + } + throw error; + } }; const handleCancel = () => { setIsEditing(false); setFormState(getInitialFormState(product)); + setSaveError(''); }; return ( @@ -99,9 +112,17 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow value={formState.barcode} onChange={handleChange('barcode')} size="small" + error={Boolean(saveError)} + helperText={saveError || undefined} InputProps={{ endAdornment: ( - ), diff --git a/src/db/index.ts b/src/db/index.ts index b5d50ac..8526a51 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -51,6 +51,45 @@ export class StockFillDB extends Dexie { })), ); }); + + this.version(3) + .stores({ + products: + 'id, name, category, barcode, archived, created_at, updated_at', + areas: 'id, name, created_at, updated_at', + pickLists: 'id, area_id, created_at, completed_at', + pickItems: 'id, pick_list_id, product_id, status, created_at, updated_at', + categories: 'id, name, created_at, updated_at', + }) + .upgrade(async (tx) => { + const products = await tx.table('products').toArray(); + const seenBarcodes = new Set(); + const now = Date.now(); + + await Promise.all( + products.map((product) => { + if (!product.barcode) return undefined; + + if (seenBarcodes.has(product.barcode)) { + return tx.table('products').update(product.id, { + barcode: undefined, + updated_at: now, + }); + } + + seenBarcodes.add(product.barcode); + return undefined; + }), + ); + }); + + this.version(4).stores({ + products: 'id, name, category, &barcode, archived, created_at, updated_at', + areas: 'id, name, created_at, updated_at', + pickLists: 'id, area_id, created_at, completed_at', + pickItems: 'id, pick_list_id, product_id, status, created_at, updated_at', + categories: 'id, name, created_at, updated_at', + }); } } diff --git a/src/screens/ManageProductsScreen.test.tsx b/src/screens/ManageProductsScreen.test.tsx index 0777272..ce55e41 100644 --- a/src/screens/ManageProductsScreen.test.tsx +++ b/src/screens/ManageProductsScreen.test.tsx @@ -1,29 +1,36 @@ import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { MemoryRouter } from 'react-router-dom'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { ManageProductsScreen } from './ManageProductsScreen'; +let mockScannedBarcode = '123456'; +const mockUseProducts = vi.fn(); +const mockUseCategories = vi.fn(); + +const mockDb = { + products: { + add: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + where: vi.fn(), + }, +}; + vi.mock('../hooks/dataHooks', () => ({ - useProducts: () => [], - useCategories: () => [{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }], + useProducts: () => mockUseProducts(), + useCategories: () => mockUseCategories(), })); vi.mock('../context/DBProvider', () => ({ - useDatabase: () => ({ - products: { - add: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - }, - }), + useDatabase: () => mockDb, })); vi.mock('../components/BarcodeScannerView', () => ({ BarcodeScannerView: ({ onDetected }: { onDetected?: (code: string) => void }) => ( - ), @@ -36,6 +43,18 @@ describe('ManageProductsScreen barcode lookup', () => { afterEach(() => server.resetHandlers()); afterAll(() => server.close()); + beforeEach(() => { + mockUseProducts.mockReturnValue([]); + mockUseCategories.mockReturnValue([{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }]); + mockScannedBarcode = '123456'; + Object.values(mockDb.products).forEach((fn) => fn.mockReset()); + mockDb.products.where.mockImplementation(() => ({ + equals: (value: string) => ({ + first: () => Promise.resolve(mockUseProducts().find((product: any) => product.barcode === value)), + }), + })); + }); + it('prefills the product name after scanning a barcode', async () => { server.use( http.get('https://world.openfoodfacts.org/api/v2/product/123456.json', () => @@ -62,5 +81,83 @@ describe('ManageProductsScreen barcode lookup', () => { expect(screen.getByLabelText(/name/i)).toHaveValue('OFF Test Product'); }); }); + + it('prevents adding a product with a duplicate barcode', 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, + }, + ]); + + const user = userEvent.setup(); + render( + + + , + ); + + await user.type(screen.getByLabelText(/name/i), 'New Product'); + await user.click(screen.getByRole('button', { name: /scan barcode/i })); + await user.click(screen.getByRole('button', { name: /mock scan/i })); + await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: /scan barcode/i })); + await user.click(screen.getByRole('button', { name: /save product/i })); + + expect(await screen.findByText(/barcode is already assigned/i)).toBeVisible(); + expect(mockDb.products.add).not.toHaveBeenCalled(); + }); + + it('prevents updating a product to use an existing barcode', 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 existing product/i)); + const barcodeField = screen.getByLabelText(/barcode/i); + fireEvent.change(barcodeField, { target: { value: '654321' } }); + expect(barcodeField).toHaveValue('654321'); + await user.click(screen.getByLabelText(/save product/i)); + + await waitFor(() => { + expect(barcodeField).toHaveAccessibleDescription('This barcode is already assigned to another product.'); + expect(barcodeField).toHaveAttribute('aria-invalid', 'true'); + }); + expect(mockDb.products.update).not.toHaveBeenCalled(); + }); }); diff --git a/src/screens/ManageProductsScreen.tsx b/src/screens/ManageProductsScreen.tsx index 0e1973e..e1d6b22 100644 --- a/src/screens/ManageProductsScreen.tsx +++ b/src/screens/ManageProductsScreen.tsx @@ -30,6 +30,7 @@ export const ManageProductsScreen = () => { const [name, setName] = useState(''); const [category, setCategory] = useState(''); const [barcode, setBarcode] = useState(''); + const [barcodeError, setBarcodeError] = useState(''); const [scannerOpen, setScannerOpen] = useState(false); const [lookupStatus, setLookupStatus] = useState<'idle' | 'loading' | 'found' | 'notfound' | 'offline'>( 'idle', @@ -66,6 +67,30 @@ export const ManageProductsScreen = () => { return Array.from(new Set([...categoryNames, ...productCategories])); }, [categories, products]); + const findBarcodeConflict = useCallback( + (value?: string, productId?: string) => + value + ? products.find((product) => product.barcode === value && product.id !== productId) + : undefined, + [products], + ); + + const assertUniqueBarcode = useCallback( + async (value?: string, productId?: string) => { + if (!value) return; + + const conflict = + findBarcodeConflict(value, productId) ?? (await db.products.where('barcode').equals(value).first()); + + if (conflict && conflict.id !== productId) { + const error = new Error('This barcode is already assigned to another product.'); + error.name = 'DuplicateBarcodeError'; + throw error; + } + }, + [db.products, findBarcodeConflict], + ); + useEffect(() => { if (categoryOptions.length === 0) return; if (!categoryOptions.includes(category)) { @@ -94,10 +119,22 @@ export const ManageProductsScreen = () => { setLookupStatus('idle'); setExternalProduct(null); } + setBarcodeError(''); }, [barcode]); const addProduct = async () => { if (!name || !category) return; + + try { + await assertUniqueBarcode(barcode || undefined); + } catch (error) { + if (error instanceof Error && error.name === 'DuplicateBarcodeError') { + setBarcodeError(error.message); + return; + } + throw error; + } + await db.products.add({ id: uuidv4(), name, @@ -121,6 +158,8 @@ export const ManageProductsScreen = () => { barcode?: string; }, ) => { + await assertUniqueBarcode(updates.barcode, productId); + await db.products.update(productId, { ...updates, unit_type: DEFAULT_UNIT_TYPE, @@ -185,6 +224,8 @@ export const ManageProductsScreen = () => { label="Barcode" value={barcode} onChange={(event) => setBarcode(event.target.value)} + error={Boolean(barcodeError)} + helperText={barcodeError || undefined} InputProps={{ endAdornment: (