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/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 cd83768..16af4b9 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 {
@@ -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/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..7eccc1f 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,52 @@ export const ManageProductsScreen = () => {
- setSearch(event.target.value)}
- InputProps={{ startAdornment: {} }}
- />
+
+ setSearch(event.target.value)}
+ InputProps={{ startAdornment: {} }}
+ fullWidth
+ />
+ setSelectedCategory(event.target.value)}
+ sx={{ minWidth: { sm: 180 } }}
+ inputProps={{ 'aria-label': 'Category filter' }}
+ >
+
+ {categoryOptions.map((cat) => (
+
+ ))}
+
+
Add Product
setName(event.target.value)}
+ onChange={(event) => {
+ setName(event.target.value);
+ setNameError('');
+ }}
+ error={Boolean(nameError)}
+ helperText={nameError || undefined}
InputProps={
name
? {
endAdornment: (
-
- {filtered.map((product) => (
-
- ))}
+ {sortedFiltered.length === 0 ? (
+
+ No products match your search and category filter.
+
+ ) : (
+ sortedFiltered.map((product) => (
+
+ ))
+ )}