From 11db402bd3ef551df4e70aedaf0c433f8513a9e7 Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Mon, 24 Nov 2025 07:20:55 +1000 Subject: [PATCH 1/9] Deduplicate seed data and add tests --- src/db/seed.test.ts | 121 ++++++++++++++++++++++++++++++++++++++++++++ src/db/seed.ts | 78 ++++++++++++++++++++++------ 2 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 src/db/seed.test.ts diff --git a/src/db/seed.test.ts b/src/db/seed.test.ts new file mode 100644 index 0000000..f430054 --- /dev/null +++ b/src/db/seed.test.ts @@ -0,0 +1,121 @@ +import { randomUUID } from 'crypto'; +import { describe, expect, it } from 'vitest'; +import { seedAreas, seedCategories, seedDatabase, seedProducts } from './seed'; +import { Area } from '../models/Area'; +import { Category } from '../models/Category'; +import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE, Product } from '../models/Product'; +import { StockFillDB } from './index'; + +const normalizeName = (name: string) => name.trim().toLowerCase(); + +class MockTable { + constructor(public items: T[] = []) {} + + async count() { + return this.items.length; + } + + async bulkAdd(records: T[]) { + this.items.push(...records); + } + + async bulkDelete(ids: string[]) { + this.items = this.items.filter((item) => !ids.includes(item.id)); + } + + async toArray() { + return [...this.items]; + } +} + +const buildArea = (overrides: Partial = {}): Area => ({ + id: overrides.id ?? randomUUID(), + name: overrides.name ?? 'Area', + created_at: overrides.created_at ?? Date.now(), + updated_at: overrides.updated_at ?? Date.now(), +}); + +const buildProduct = (overrides: Partial = {}): Product => ({ + id: overrides.id ?? randomUUID(), + name: overrides.name ?? 'Product', + category: overrides.category ?? 'Category', + unit_type: overrides.unit_type ?? DEFAULT_UNIT_TYPE, + bulk_name: overrides.bulk_name ?? DEFAULT_BULK_NAME, + barcode: overrides.barcode, + archived: overrides.archived ?? false, + created_at: overrides.created_at ?? Date.now(), + updated_at: overrides.updated_at ?? Date.now(), +}); + +const buildCategory = (overrides: Partial = {}): Category => ({ + id: overrides.id ?? randomUUID(), + name: overrides.name ?? 'Category', + created_at: overrides.created_at ?? Date.now(), + updated_at: overrides.updated_at ?? Date.now(), +}); + +const createMockDb = (options: { + areas?: Area[]; + products?: Product[]; + categories?: Category[]; +} = {}) => { + const db = { + areas: new MockTable(options.areas ?? []), + products: new MockTable(options.products ?? []), + categories: new MockTable(options.categories ?? []), + pickLists: new MockTable(), + pickItems: new MockTable(), + } as unknown as StockFillDB; + + return db; +}; + +describe('seedDatabase', () => { + it('deduplicates seeded areas, categories, and products', async () => { + const duplicateSeedArea = buildArea({ name: seedAreas[0] }); + const trailingSpaceArea = buildArea({ name: `${seedAreas[0]} ` }); + const customArea = buildArea({ name: 'Produce' }); + + const duplicateProduct = buildProduct({ name: seedProducts[0].name, category: seedProducts[0].category }); + const duplicateProductWithWhitespace = buildProduct({ name: `${seedProducts[0].name} `, category: seedProducts[0].category }); + const customProduct = buildProduct({ name: 'Custom Item', category: 'Specials' }); + + const duplicateCategory = buildCategory({ name: seedCategories[0] }); + const trailingSpaceCategory = buildCategory({ name: `${seedCategories[0]} ` }); + + const db = createMockDb({ + areas: [duplicateSeedArea, trailingSpaceArea, customArea], + products: [duplicateProduct, duplicateProductWithWhitespace, customProduct], + categories: [duplicateCategory, trailingSpaceCategory], + }); + + await seedDatabase(db); + + const areas = await db.areas.toArray(); + const areaNames = areas.map((area) => normalizeName(area.name)); + const seededAreaNames = new Set(seedAreas.map(normalizeName)); + + expect(areaNames.filter((name) => name === normalizeName(seedAreas[0]))).toHaveLength(1); + expect(new Set(areaNames.filter((name) => seededAreaNames.has(name)))).toEqual(seededAreaNames); + expect(areaNames).toContain(normalizeName(customArea.name)); + + const products = await db.products.toArray(); + const seededProductNames = new Set(seedProducts.map((product) => normalizeName(product.name))); + const productNamesInDb = products.map((product) => normalizeName(product.name)); + + expect(productNamesInDb.filter((name) => name === normalizeName(seedProducts[0].name))).toHaveLength(1); + expect(new Set(productNamesInDb.filter((name) => seededProductNames.has(name)))).toEqual( + seededProductNames, + ); + expect(productNamesInDb).toContain(normalizeName(customProduct.name)); + + const categories = await db.categories.toArray(); + const seededCategoryNames = new Set(seedCategories.map(normalizeName)); + const categoryNamesInDb = categories.map((category) => normalizeName(category.name)); + + expect(categoryNamesInDb.filter((name) => name === normalizeName(seedCategories[0]))).toHaveLength(1); + expect(new Set(categoryNamesInDb.filter((name) => seededCategoryNames.has(name)))).toEqual( + seededCategoryNames, + ); + }); +}); diff --git a/src/db/seed.ts b/src/db/seed.ts index 5e60521..7d6a497 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -1,10 +1,13 @@ +import { Table } from 'dexie'; import { v4 as uuidv4 } from 'uuid'; import { StockFillDB } from './index'; import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE } from '../models/Product'; const now = () => Date.now(); -const seedProducts = [ +export const seedAreas = ['Drinks', 'Snacks', 'Dairy']; + +export const seedProducts = [ { name: 'Nutrient Water Endurance', category: 'Drinks' }, { name: 'Nutrient Water Focus', category: 'Drinks' }, { name: 'Cocobella Choc', category: 'Drinks' }, @@ -33,10 +36,39 @@ const seedProducts = [ { name: 'Mars Bar', category: 'Chocolates' }, ]; -const seedCategories = Array.from( +export const seedCategories = Array.from( new Set(seedProducts.map(({ category }) => category)), ).sort(); +const normalizeName = (name: string) => name.trim().toLowerCase(); + +const dedupeSeedRecords = async ( + table: Table, + seededNames: Set, +) => { + const existing = await table.toArray(); + const seen = new Set(); + const duplicateIds: string[] = []; + + existing.forEach((record) => { + const normalized = normalizeName(record.name); + if (!seededNames.has(normalized)) return; + + if (seen.has(normalized)) { + duplicateIds.push(record.id); + return; + } + + seen.add(normalized); + }); + + if (duplicateIds.length > 0) { + await table.bulkDelete(duplicateIds); + } + + return seen; +}; + const buildProductRecord = (product: { name: string; category: string }) => ({ id: uuidv4(), name: product.name, @@ -49,19 +81,30 @@ const buildProductRecord = (product: { name: string; category: string }) => ({ }); export const seedDatabase = async (db: StockFillDB) => { - const areaCount = await db.areas.count(); - if (areaCount === 0) { - await db.areas.bulkAdd([ - { id: uuidv4(), name: 'Drinks', created_at: now(), updated_at: now() }, - { id: uuidv4(), name: 'Snacks', created_at: now(), updated_at: now() }, - { id: uuidv4(), name: 'Dairy', created_at: now(), updated_at: now() }, - ]); + const seededAreaNames = new Set(seedAreas.map(normalizeName)); + const existingSeedAreas = await dedupeSeedRecords(db.areas, seededAreaNames); + const missingAreas = seedAreas.filter((area) => !existingSeedAreas.has(normalizeName(area))); + + if (missingAreas.length > 0) { + await db.areas.bulkAdd( + missingAreas.map((name) => ({ + id: uuidv4(), + name, + created_at: now(), + updated_at: now(), + })), + ); } - const categoryCount = await db.categories.count(); - if (categoryCount === 0) { + const seededCategoryNames = new Set(seedCategories.map(normalizeName)); + const existingSeedCategories = await dedupeSeedRecords(db.categories, seededCategoryNames); + const missingCategories = seedCategories.filter( + (category) => !existingSeedCategories.has(normalizeName(category)), + ); + + if (missingCategories.length > 0) { await db.categories.bulkAdd( - seedCategories.map((category) => ({ + missingCategories.map((category) => ({ id: uuidv4(), name: category, created_at: now(), @@ -70,8 +113,13 @@ export const seedDatabase = async (db: StockFillDB) => { ); } - const productCount = await db.products.count(); - if (productCount === 0) { - await db.products.bulkAdd(seedProducts.map(buildProductRecord)); + const seededProductNames = new Set(seedProducts.map(({ name }) => normalizeName(name))); + const existingSeedProducts = await dedupeSeedRecords(db.products, seededProductNames); + const missingProducts = seedProducts.filter( + (product) => !existingSeedProducts.has(normalizeName(product.name)), + ); + + if (missingProducts.length > 0) { + await db.products.bulkAdd(missingProducts.map(buildProductRecord)); } }; From 46793af5d39159f25f7f0e25e7164e59eace6f2a Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Mon, 24 Nov 2025 07:23:19 +1000 Subject: [PATCH 2/9] Sort manage products list --- src/screens/ManageProductsScreen.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/screens/ManageProductsScreen.tsx b/src/screens/ManageProductsScreen.tsx index 926389f..6896949 100644 --- a/src/screens/ManageProductsScreen.tsx +++ b/src/screens/ManageProductsScreen.tsx @@ -147,6 +147,11 @@ export const ManageProductsScreen = () => { [products, search], ); + const sortedFiltered = useMemo( + () => filtered.slice().sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())), + [filtered], + ); + useEffect(() => { const state = location.state as { newBarcode?: string } | null; if (state?.newBarcode) { @@ -319,7 +324,7 @@ export const ManageProductsScreen = () => { Save Product - {filtered.map((product) => ( + {sortedFiltered.map((product) => ( Date: Mon, 24 Nov 2025 07:24:54 +1000 Subject: [PATCH 3/9] Add category filter to manage products --- src/screens/ManageProductsScreen.tsx | 48 ++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/src/screens/ManageProductsScreen.tsx b/src/screens/ManageProductsScreen.tsx index 926389f..35d07c3 100644 --- a/src/screens/ManageProductsScreen.tsx +++ b/src/screens/ManageProductsScreen.tsx @@ -29,6 +29,7 @@ export const ManageProductsScreen = () => { const categories = useCategories(); const location = useLocation(); const [search, setSearch] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('all'); const [name, setName] = useState(''); const [category, setCategory] = useState(''); const [barcode, setBarcode] = useState(''); @@ -139,12 +140,22 @@ export const ManageProductsScreen = () => { } }, [category, categoryOptions]); + useEffect(() => { + if (selectedCategory !== 'all' && !categoryOptions.includes(selectedCategory)) { + setSelectedCategory('all'); + } + }, [categoryOptions, selectedCategory]); + const filtered = useMemo( () => - products.filter((p) => - `${p.name} ${p.category}`.toLowerCase().includes(search.toLowerCase()), - ), - [products, search], + products.filter((p) => { + const matchesSearch = `${p.name} ${p.category}` + .toLowerCase() + .includes(search.toLowerCase()); + const matchesCategory = selectedCategory === 'all' || p.category === selectedCategory; + return matchesSearch && matchesCategory; + }), + [products, search, selectedCategory], ); useEffect(() => { @@ -240,12 +251,29 @@ export const ManageProductsScreen = () => { - setSearch(event.target.value)} - InputProps={{ startAdornment: {} }} - /> + + setSearch(event.target.value)} + InputProps={{ startAdornment: {} }} + fullWidth + /> + setSelectedCategory(event.target.value)} + sx={{ minWidth: { sm: 180 } }} + > + All categories + {categoryOptions.map((cat) => ( + + {cat} + + ))} + + Add Product Date: Mon, 24 Nov 2025 07:25:20 +1000 Subject: [PATCH 4/9] Remove unit text from ProductRow view --- src/components/ProductRow.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ProductRow.tsx b/src/components/ProductRow.tsx index 38df5ba..97510fb 100644 --- a/src/components/ProductRow.tsx +++ b/src/components/ProductRow.tsx @@ -17,7 +17,7 @@ import EditIcon from '@mui/icons-material/Edit'; import CheckIcon from '@mui/icons-material/Check'; import CloseIcon from '@mui/icons-material/Close'; import { ChangeEvent, useEffect, useState } from 'react'; -import { DEFAULT_UNIT_TYPE, Product } from '../models/Product'; +import { Product } from '../models/Product'; import { BarcodeScannerView } from './BarcodeScannerView'; interface ProductRowProps { @@ -139,7 +139,7 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
{product.name} - {product.category} • {product.unit_type || DEFAULT_UNIT_TYPE} + {product.category}
{product.barcode ? ( From 05a4347271fcb1df69e3ad398f4089a8bc30fe37 Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Mon, 24 Nov 2025 07:28:54 +1000 Subject: [PATCH 5/9] Refine product row layout --- src/components/ProductRow.tsx | 71 +++++++++++++++++------------------ 1 file changed, 35 insertions(+), 36 deletions(-) diff --git a/src/components/ProductRow.tsx b/src/components/ProductRow.tsx index 38df5ba..4093441 100644 --- a/src/components/ProductRow.tsx +++ b/src/components/ProductRow.tsx @@ -1,6 +1,7 @@ import { + Box, + Button, Card, - CardActions, CardContent, Dialog, DialogContent, @@ -10,7 +11,6 @@ import { Stack, TextField, Typography, - Button, } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; import EditIcon from '@mui/icons-material/Edit'; @@ -89,7 +89,7 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow return ( - + {isEditing ? ( @@ -133,44 +133,43 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow Scan Barcode )} + + + + + + + + ) : ( - -
- {product.name} - - {product.category} • {product.unit_type || DEFAULT_UNIT_TYPE} - -
- {product.barcode ? ( - - Barcode: {product.barcode} - - ) : null} + + + + + {product.name} + + + {product.category} • {product.unit_type || DEFAULT_UNIT_TYPE} + + + {product.barcode ? ( + + Barcode: {product.barcode} + + ) : null} + + + setIsEditing(true)} size="small"> + + + onDelete(product.id)} size="small"> + + + )}
- - {isEditing ? ( - <> - - - - - - - - ) : ( - <> - setIsEditing(true)}> - - - onDelete(product.id)}> - - - - )} - setIsScannerOpen(false)} fullWidth> Scan Barcode From 141c2af06fa12019bfbc0b934949f1e7651ec8d6 Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Mon, 24 Nov 2025 07:32:20 +1000 Subject: [PATCH 6/9] Show empty results message on product filters --- src/screens/ManageProductsScreen.test.tsx | 38 +++++++++++++++++++++++ src/screens/ManageProductsScreen.tsx | 24 ++++++++------ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/screens/ManageProductsScreen.test.tsx b/src/screens/ManageProductsScreen.test.tsx index 575dde9..468b903 100644 --- a/src/screens/ManageProductsScreen.test.tsx +++ b/src/screens/ManageProductsScreen.test.tsx @@ -308,3 +308,41 @@ describe('ManageProductsScreen deletion safeguards', () => { }); }); +describe('ManageProductsScreen filtering feedback', () => { + it('informs the user when no products match the search and category filter', async () => { + mockUseProducts.mockReturnValue([ + { + id: 'prod-1', + name: 'Chips', + category: 'Snacks', + unit_type: 'unit', + bulk_name: 'pack', + archived: false, + created_at: 0, + updated_at: 0, + }, + ]); + mockUseCategories.mockReturnValue([ + { id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }, + { id: 'cat-2', name: 'Drinks', created_at: 0, updated_at: 0 }, + ]); + + const user = userEvent.setup(); + render( + + + , + ); + + await user.type(screen.getByPlaceholderText(/search/i), 'Soda'); + + const [filterSelect] = screen.getAllByLabelText(/category/i); + await user.click(filterSelect); + await user.click(screen.getByRole('option', { name: /drinks/i })); + + expect( + await screen.findByText(/no products match your search and category filter\./i), + ).toBeVisible(); + }); +}); + diff --git a/src/screens/ManageProductsScreen.tsx b/src/screens/ManageProductsScreen.tsx index a781b3d..fd52c9b 100644 --- a/src/screens/ManageProductsScreen.tsx +++ b/src/screens/ManageProductsScreen.tsx @@ -352,15 +352,21 @@ export const ManageProductsScreen = () => { Save Product
- {sortedFiltered.map((product) => ( - - ))} + {sortedFiltered.length === 0 ? ( + + No products match your search and category filter. + + ) : ( + sortedFiltered.map((product) => ( + + )) + )} setScannerOpen(false)} fullWidth> Scan Barcode From b25c5aa23ab03cb4373fdd5604f69f67e5cf005b Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Mon, 24 Nov 2025 07:33:36 +1000 Subject: [PATCH 7/9] Validate product name uniqueness --- src/components/ProductRow.tsx | 31 ++++++--- src/screens/ManageProductsScreen.test.tsx | 78 +++++++++++++++++++++++ src/screens/ManageProductsScreen.tsx | 52 ++++++++++++++- 3 files changed, 149 insertions(+), 12 deletions(-) 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: ( - ), From d4f184f69c97dacf0d53695fba1eebc9e4d6e7f0 Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Mon, 24 Nov 2025 07:37:31 +1000 Subject: [PATCH 8/9] Add test for ProductRow read-only display --- src/components/ProductRow.test.tsx | 35 ++++++++++++++++++++++++++++++ src/components/ProductRow.tsx | 4 ++-- 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 src/components/ProductRow.test.tsx diff --git a/src/components/ProductRow.test.tsx b/src/components/ProductRow.test.tsx new file mode 100644 index 0000000..c0bee20 --- /dev/null +++ b/src/components/ProductRow.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ProductRow } from './ProductRow'; +import { Product } from '../models/Product'; + +const product: Product = { + id: 'prod-1', + name: 'Sparkling Water', + category: 'Drinks', + unit_type: 'bottle', + barcode: '123456', + archived: false, + created_at: 0, + updated_at: 0, +}; + +const categories = ['Drinks', 'Snacks']; + +describe('ProductRow', () => { + it('shows name, category, and barcode without unit text in read-only mode', () => { + render( + , + ); + + expect(screen.getByText('Sparkling Water')).toBeInTheDocument(); + expect(screen.getByText('Drinks')).toBeInTheDocument(); + expect(screen.getByText(/Barcode: 123456/)).toBeInTheDocument(); + expect(screen.queryByText(/bottle/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/ProductRow.tsx b/src/components/ProductRow.tsx index 38df5ba..97510fb 100644 --- a/src/components/ProductRow.tsx +++ b/src/components/ProductRow.tsx @@ -17,7 +17,7 @@ import EditIcon from '@mui/icons-material/Edit'; import CheckIcon from '@mui/icons-material/Check'; import CloseIcon from '@mui/icons-material/Close'; import { ChangeEvent, useEffect, useState } from 'react'; -import { DEFAULT_UNIT_TYPE, Product } from '../models/Product'; +import { Product } from '../models/Product'; import { BarcodeScannerView } from './BarcodeScannerView'; interface ProductRowProps { @@ -139,7 +139,7 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
{product.name} - {product.category} • {product.unit_type || DEFAULT_UNIT_TYPE} + {product.category}
{product.barcode ? ( From d01ba0079ea2acb27d80faa456f0627e721a622a Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Mon, 24 Nov 2025 07:42:50 +1000 Subject: [PATCH 9/9] Clarify category selectors for manage products --- e2e/picklist.spec.ts | 2 +- src/screens/ManageProductsScreen.tsx | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/e2e/picklist.spec.ts b/e2e/picklist.spec.ts index c30cf4c..066f6f6 100644 --- a/e2e/picklist.spec.ts +++ b/e2e/picklist.spec.ts @@ -34,7 +34,7 @@ test.describe('Active pick list', () => { await page.getByLabel('Name').click(); await page.getByLabel('Name').fill('Playwright Cola'); - await page.getByLabel('Category').click(); + await page.getByLabel('Add product category').click(); await page.getByRole('option', { name: 'Drinks' }).click(); await page.getByRole('button', { name: 'Save Product' }).click(); diff --git a/src/screens/ManageProductsScreen.tsx b/src/screens/ManageProductsScreen.tsx index 6e7f1bf..7eccc1f 100644 --- a/src/screens/ManageProductsScreen.tsx +++ b/src/screens/ManageProductsScreen.tsx @@ -303,10 +303,11 @@ export const ManageProductsScreen = () => { /> setSelectedCategory(event.target.value)} sx={{ minWidth: { sm: 180 } }} + inputProps={{ 'aria-label': 'Category filter' }} > All categories {categoryOptions.map((cat) => ( @@ -347,7 +348,7 @@ export const ManageProductsScreen = () => { /> setCategory(event.target.value)} disabled={categoryOptions.length === 0}