diff --git a/src/components/ProductRow.tsx b/src/components/ProductRow.tsx index 97510fb..51fe6d3 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'; @@ -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,15 +88,22 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow const handleCancel = () => { setIsEditing(false); setFormState(getInitialFormState(product)); - setSaveError(''); + setFieldErrors({}); }; return ( - + {isEditing ? ( - + { setFormState((prev) => ({ ...prev, barcode: '' })); - setSaveError(''); + setFieldErrors((prev) => ({ ...prev, barcode: undefined })); }} > Clear @@ -133,6 +144,14 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow Scan Barcode )} + + + + + + + + ) : ( @@ -150,27 +169,6 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow )} - - {isEditing ? ( - <> - - - - - - - - ) : ( - <> - setIsEditing(true)}> - - - onDelete(product.id)}> - - - - )} - setIsScannerOpen(false)} fullWidth> Scan Barcode 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)); } }; diff --git a/src/screens/ManageProductsScreen.test.tsx b/src/screens/ManageProductsScreen.test.tsx index 575dde9..8b545fd 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', () => { @@ -308,3 +386,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 926389f..6e7f1bf 100644 --- a/src/screens/ManageProductsScreen.tsx +++ b/src/screens/ManageProductsScreen.tsx @@ -29,10 +29,12 @@ 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(''); 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 +80,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 +109,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(); @@ -139,12 +170,27 @@ 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], + ); + + const sortedFiltered = useMemo( + () => filtered.slice().sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())), + [filtered], ); useEffect(() => { @@ -167,8 +213,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 +246,7 @@ export const ManageProductsScreen = () => { }); setName(''); setBarcode(''); + setNameError(''); setFeedback({ text: 'Product added.', severity: 'success' }); }; @@ -206,6 +258,7 @@ export const ManageProductsScreen = () => { barcode?: string; }, ) => { + await assertUniqueName(updates.name, productId); await assertUniqueBarcode(updates.barcode, productId); await db.products.update(productId, { @@ -240,23 +293,51 @@ 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 setName(event.target.value)} + onChange={(event) => { + setName(event.target.value); + setNameError(''); + }} + error={Boolean(nameError)} + helperText={nameError || undefined} InputProps={ name ? { endAdornment: ( - ), @@ -319,15 +400,21 @@ export const ManageProductsScreen = () => { Save Product - {filtered.map((product) => ( - - ))} + {sortedFiltered.length === 0 ? ( + + No products match your search and category filter. + + ) : ( + sortedFiltered.map((product) => ( + + )) + )} setScannerOpen(false)} fullWidth> Scan Barcode