Merge branch 'main' into codex/refactor-productrow-layout-g62j3n
This commit is contained in:
@@ -34,7 +34,7 @@ test.describe('Active pick list', () => {
|
|||||||
|
|
||||||
await page.getByLabel('Name').click();
|
await page.getByLabel('Name').click();
|
||||||
await page.getByLabel('Name').fill('Playwright Cola');
|
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('option', { name: 'Drinks' }).click();
|
||||||
await page.getByRole('button', { name: 'Save Product' }).click();
|
await page.getByRole('button', { name: 'Save Product' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<ProductRow
|
||||||
|
product={product}
|
||||||
|
categories={categories}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Sparkling Water')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Drinks')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Barcode: 123456/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/bottle/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,7 +17,7 @@ import EditIcon from '@mui/icons-material/Edit';
|
|||||||
import CheckIcon from '@mui/icons-material/Check';
|
import CheckIcon from '@mui/icons-material/Check';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import { ChangeEvent, useEffect, useState } from 'react';
|
import { ChangeEvent, useEffect, useState } from 'react';
|
||||||
import { DEFAULT_UNIT_TYPE, Product } from '../models/Product';
|
import { Product } from '../models/Product';
|
||||||
import { BarcodeScannerView } from './BarcodeScannerView';
|
import { BarcodeScannerView } from './BarcodeScannerView';
|
||||||
|
|
||||||
interface ProductRowProps {
|
interface ProductRowProps {
|
||||||
@@ -50,16 +50,16 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [formState, setFormState] = useState<ProductFormState>(() => getInitialFormState(product));
|
const [formState, setFormState] = useState<ProductFormState>(() => getInitialFormState(product));
|
||||||
const [isScannerOpen, setIsScannerOpen] = useState(false);
|
const [isScannerOpen, setIsScannerOpen] = useState(false);
|
||||||
const [saveError, setSaveError] = useState('');
|
const [fieldErrors, setFieldErrors] = useState<{ name?: string; barcode?: string }>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFormState(getInitialFormState(product));
|
setFormState(getInitialFormState(product));
|
||||||
setSaveError('');
|
setFieldErrors({});
|
||||||
}, [product]);
|
}, [product]);
|
||||||
|
|
||||||
const handleChange = (field: keyof ProductFormState) => (event: ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (field: keyof ProductFormState) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
setFormState((prev) => ({ ...prev, [field]: event.target.value }));
|
setFormState((prev) => ({ ...prev, [field]: event.target.value }));
|
||||||
setSaveError('');
|
setFieldErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -71,10 +71,14 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
|
|||||||
barcode: formState.barcode || undefined,
|
barcode: formState.barcode || undefined,
|
||||||
});
|
});
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setSaveError('');
|
setFieldErrors({});
|
||||||
} catch (error) {
|
} 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') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -84,7 +88,7 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
|
|||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setFormState(getInitialFormState(product));
|
setFormState(getInitialFormState(product));
|
||||||
setSaveError('');
|
setFieldErrors({});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -92,7 +96,14 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
|
|||||||
<CardContent sx={{ p: 1.25, '&:last-child': { pb: 1.25 } }}>
|
<CardContent sx={{ p: 1.25, '&:last-child': { pb: 1.25 } }}>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
<TextField label="Name" value={formState.name} onChange={handleChange('name')} size="small" />
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
value={formState.name}
|
||||||
|
onChange={handleChange('name')}
|
||||||
|
size="small"
|
||||||
|
error={Boolean(fieldErrors.name)}
|
||||||
|
helperText={fieldErrors.name || undefined}
|
||||||
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
select
|
select
|
||||||
label="Category"
|
label="Category"
|
||||||
@@ -112,15 +123,15 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
|
|||||||
value={formState.barcode}
|
value={formState.barcode}
|
||||||
onChange={handleChange('barcode')}
|
onChange={handleChange('barcode')}
|
||||||
size="small"
|
size="small"
|
||||||
error={Boolean(saveError)}
|
error={Boolean(fieldErrors.barcode)}
|
||||||
helperText={saveError || undefined}
|
helperText={fieldErrors.barcode || undefined}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFormState((prev) => ({ ...prev, barcode: '' }));
|
setFormState((prev) => ({ ...prev, barcode: '' }));
|
||||||
setSaveError('');
|
setFieldErrors((prev) => ({ ...prev, barcode: undefined }));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
|
|||||||
@@ -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<T extends { id: string; name: string }> {
|
||||||
|
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> = {}): 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> = {}): 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> = {}): 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<Area>(options.areas ?? []),
|
||||||
|
products: new MockTable<Product>(options.products ?? []),
|
||||||
|
categories: new MockTable<Category>(options.categories ?? []),
|
||||||
|
pickLists: new MockTable<any>(),
|
||||||
|
pickItems: new MockTable<any>(),
|
||||||
|
} 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+63
-15
@@ -1,10 +1,13 @@
|
|||||||
|
import { Table } from 'dexie';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { StockFillDB } from './index';
|
import { StockFillDB } from './index';
|
||||||
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE } from '../models/Product';
|
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE } from '../models/Product';
|
||||||
|
|
||||||
const now = () => Date.now();
|
const now = () => Date.now();
|
||||||
|
|
||||||
const seedProducts = [
|
export const seedAreas = ['Drinks', 'Snacks', 'Dairy'];
|
||||||
|
|
||||||
|
export const seedProducts = [
|
||||||
{ name: 'Nutrient Water Endurance', category: 'Drinks' },
|
{ name: 'Nutrient Water Endurance', category: 'Drinks' },
|
||||||
{ name: 'Nutrient Water Focus', category: 'Drinks' },
|
{ name: 'Nutrient Water Focus', category: 'Drinks' },
|
||||||
{ name: 'Cocobella Choc', category: 'Drinks' },
|
{ name: 'Cocobella Choc', category: 'Drinks' },
|
||||||
@@ -33,10 +36,39 @@ const seedProducts = [
|
|||||||
{ name: 'Mars Bar', category: 'Chocolates' },
|
{ name: 'Mars Bar', category: 'Chocolates' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const seedCategories = Array.from(
|
export const seedCategories = Array.from(
|
||||||
new Set(seedProducts.map(({ category }) => category)),
|
new Set(seedProducts.map(({ category }) => category)),
|
||||||
).sort();
|
).sort();
|
||||||
|
|
||||||
|
const normalizeName = (name: string) => name.trim().toLowerCase();
|
||||||
|
|
||||||
|
const dedupeSeedRecords = async <T extends { id: string; name: string }>(
|
||||||
|
table: Table<T>,
|
||||||
|
seededNames: Set<string>,
|
||||||
|
) => {
|
||||||
|
const existing = await table.toArray();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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 }) => ({
|
const buildProductRecord = (product: { name: string; category: string }) => ({
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
name: product.name,
|
name: product.name,
|
||||||
@@ -49,19 +81,30 @@ const buildProductRecord = (product: { name: string; category: string }) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const seedDatabase = async (db: StockFillDB) => {
|
export const seedDatabase = async (db: StockFillDB) => {
|
||||||
const areaCount = await db.areas.count();
|
const seededAreaNames = new Set(seedAreas.map(normalizeName));
|
||||||
if (areaCount === 0) {
|
const existingSeedAreas = await dedupeSeedRecords(db.areas, seededAreaNames);
|
||||||
await db.areas.bulkAdd([
|
const missingAreas = seedAreas.filter((area) => !existingSeedAreas.has(normalizeName(area)));
|
||||||
{ id: uuidv4(), name: 'Drinks', created_at: now(), updated_at: now() },
|
|
||||||
{ id: uuidv4(), name: 'Snacks', created_at: now(), updated_at: now() },
|
if (missingAreas.length > 0) {
|
||||||
{ id: uuidv4(), name: 'Dairy', created_at: now(), updated_at: now() },
|
await db.areas.bulkAdd(
|
||||||
]);
|
missingAreas.map((name) => ({
|
||||||
|
id: uuidv4(),
|
||||||
|
name,
|
||||||
|
created_at: now(),
|
||||||
|
updated_at: now(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryCount = await db.categories.count();
|
const seededCategoryNames = new Set(seedCategories.map(normalizeName));
|
||||||
if (categoryCount === 0) {
|
const existingSeedCategories = await dedupeSeedRecords(db.categories, seededCategoryNames);
|
||||||
|
const missingCategories = seedCategories.filter(
|
||||||
|
(category) => !existingSeedCategories.has(normalizeName(category)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingCategories.length > 0) {
|
||||||
await db.categories.bulkAdd(
|
await db.categories.bulkAdd(
|
||||||
seedCategories.map((category) => ({
|
missingCategories.map((category) => ({
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
name: category,
|
name: category,
|
||||||
created_at: now(),
|
created_at: now(),
|
||||||
@@ -70,8 +113,13 @@ export const seedDatabase = async (db: StockFillDB) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const productCount = await db.products.count();
|
const seededProductNames = new Set(seedProducts.map(({ name }) => normalizeName(name)));
|
||||||
if (productCount === 0) {
|
const existingSeedProducts = await dedupeSeedRecords(db.products, seededProductNames);
|
||||||
await db.products.bulkAdd(seedProducts.map(buildProductRecord));
|
const missingProducts = seedProducts.filter(
|
||||||
|
(product) => !existingSeedProducts.has(normalizeName(product.name)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingProducts.length > 0) {
|
||||||
|
await db.products.bulkAdd(missingProducts.map(buildProductRecord));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -165,6 +165,35 @@ describe('ManageProductsScreen barcode lookup', () => {
|
|||||||
expect(mockDb.products.add).not.toHaveBeenCalled();
|
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(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ManageProductsScreen />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 () => {
|
it('informs the user when barcode lookup happens offline', async () => {
|
||||||
const originalNavigator = navigator;
|
const originalNavigator = navigator;
|
||||||
Object.defineProperty(globalThis, 'navigator', {
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
@@ -237,6 +266,55 @@ describe('ManageProductsScreen barcode lookup', () => {
|
|||||||
});
|
});
|
||||||
expect(mockDb.products.update).not.toHaveBeenCalled();
|
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(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ManageProductsScreen />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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', () => {
|
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(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ManageProductsScreen />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,10 +29,12 @@ export const ManageProductsScreen = () => {
|
|||||||
const categories = useCategories();
|
const categories = useCategories();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [category, setCategory] = useState('');
|
const [category, setCategory] = useState('');
|
||||||
const [barcode, setBarcode] = useState('');
|
const [barcode, setBarcode] = useState('');
|
||||||
const [barcodeError, setBarcodeError] = useState('');
|
const [barcodeError, setBarcodeError] = useState('');
|
||||||
|
const [nameError, setNameError] = useState('');
|
||||||
const [scannerOpen, setScannerOpen] = useState(false);
|
const [scannerOpen, setScannerOpen] = useState(false);
|
||||||
const [lookupStatus, setLookupStatus] = useState<'idle' | 'loading' | 'found' | 'notfound' | 'offline'>(
|
const [lookupStatus, setLookupStatus] = useState<'idle' | 'loading' | 'found' | 'notfound' | 'offline'>(
|
||||||
'idle',
|
'idle',
|
||||||
@@ -78,6 +80,19 @@ export const ManageProductsScreen = () => {
|
|||||||
[products],
|
[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(
|
const assertUniqueBarcode = useCallback(
|
||||||
async (value?: string, productId?: string) => {
|
async (value?: string, productId?: string) => {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
@@ -94,6 +109,22 @@ export const ManageProductsScreen = () => {
|
|||||||
[db.products, findBarcodeConflict],
|
[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(
|
const addProductToAutoLists = useCallback(
|
||||||
async (product: Product, timestamp: number) => {
|
async (product: Product, timestamp: number) => {
|
||||||
const pickLists = await db.pickLists.toArray();
|
const pickLists = await db.pickLists.toArray();
|
||||||
@@ -139,12 +170,27 @@ export const ManageProductsScreen = () => {
|
|||||||
}
|
}
|
||||||
}, [category, categoryOptions]);
|
}, [category, categoryOptions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCategory !== 'all' && !categoryOptions.includes(selectedCategory)) {
|
||||||
|
setSelectedCategory('all');
|
||||||
|
}
|
||||||
|
}, [categoryOptions, selectedCategory]);
|
||||||
|
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
() =>
|
() =>
|
||||||
products.filter((p) =>
|
products.filter((p) => {
|
||||||
`${p.name} ${p.category}`.toLowerCase().includes(search.toLowerCase()),
|
const matchesSearch = `${p.name} ${p.category}`
|
||||||
),
|
.toLowerCase()
|
||||||
[products, search],
|
.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(() => {
|
useEffect(() => {
|
||||||
@@ -167,8 +213,13 @@ export const ManageProductsScreen = () => {
|
|||||||
if (!name || !category) return;
|
if (!name || !category) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await assertUniqueName(name);
|
||||||
await assertUniqueBarcode(barcode || undefined);
|
await assertUniqueBarcode(barcode || undefined);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'DuplicateNameError') {
|
||||||
|
setNameError(error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (error instanceof Error && error.name === 'DuplicateBarcodeError') {
|
if (error instanceof Error && error.name === 'DuplicateBarcodeError') {
|
||||||
setBarcodeError(error.message);
|
setBarcodeError(error.message);
|
||||||
return;
|
return;
|
||||||
@@ -195,6 +246,7 @@ export const ManageProductsScreen = () => {
|
|||||||
});
|
});
|
||||||
setName('');
|
setName('');
|
||||||
setBarcode('');
|
setBarcode('');
|
||||||
|
setNameError('');
|
||||||
setFeedback({ text: 'Product added.', severity: 'success' });
|
setFeedback({ text: 'Product added.', severity: 'success' });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -206,6 +258,7 @@ export const ManageProductsScreen = () => {
|
|||||||
barcode?: string;
|
barcode?: string;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
|
await assertUniqueName(updates.name, productId);
|
||||||
await assertUniqueBarcode(updates.barcode, productId);
|
await assertUniqueBarcode(updates.barcode, productId);
|
||||||
|
|
||||||
await db.products.update(productId, {
|
await db.products.update(productId, {
|
||||||
@@ -240,23 +293,52 @@ export const ManageProductsScreen = () => {
|
|||||||
<Button component={RouterLink} to="/categories" variant="outlined" sx={{ alignSelf: 'flex-start' }}>
|
<Button component={RouterLink} to="/categories" variant="outlined" sx={{ alignSelf: 'flex-start' }}>
|
||||||
Edit Categories
|
Edit Categories
|
||||||
</Button>
|
</Button>
|
||||||
<TextField
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}>
|
||||||
placeholder="Search"
|
<TextField
|
||||||
value={search}
|
placeholder="Search"
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
value={search}
|
||||||
InputProps={{ startAdornment: <InputAdornment position="start">{<SearchIcon />}</InputAdornment> }}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
/>
|
InputProps={{ startAdornment: <InputAdornment position="start">{<SearchIcon />}</InputAdornment> }}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Filter by category"
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={(event) => setSelectedCategory(event.target.value)}
|
||||||
|
sx={{ minWidth: { sm: 180 } }}
|
||||||
|
inputProps={{ 'aria-label': 'Category filter' }}
|
||||||
|
>
|
||||||
|
<MenuItem value="all">All categories</MenuItem>
|
||||||
|
{categoryOptions.map((cat) => (
|
||||||
|
<MenuItem key={cat} value={cat}>
|
||||||
|
{cat}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Stack>
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
<Typography variant="subtitle1">Add Product</Typography>
|
<Typography variant="subtitle1">Add Product</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
label="Name"
|
label="Name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(event) => setName(event.target.value)}
|
onChange={(event) => {
|
||||||
|
setName(event.target.value);
|
||||||
|
setNameError('');
|
||||||
|
}}
|
||||||
|
error={Boolean(nameError)}
|
||||||
|
helperText={nameError || undefined}
|
||||||
InputProps={
|
InputProps={
|
||||||
name
|
name
|
||||||
? {
|
? {
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<Button onClick={() => setName('')} size="small">
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setName('');
|
||||||
|
setNameError('');
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
@@ -266,7 +348,7 @@ export const ManageProductsScreen = () => {
|
|||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
select
|
select
|
||||||
label="Category"
|
label="Add product category"
|
||||||
value={category}
|
value={category}
|
||||||
onChange={(event) => setCategory(event.target.value)}
|
onChange={(event) => setCategory(event.target.value)}
|
||||||
disabled={categoryOptions.length === 0}
|
disabled={categoryOptions.length === 0}
|
||||||
@@ -319,15 +401,21 @@ export const ManageProductsScreen = () => {
|
|||||||
Save Product
|
Save Product
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
{filtered.map((product) => (
|
{sortedFiltered.length === 0 ? (
|
||||||
<ProductRow
|
<Typography variant="body2" color="text.secondary">
|
||||||
key={product.id}
|
No products match your search and category filter.
|
||||||
product={product}
|
</Typography>
|
||||||
categories={categoryOptions}
|
) : (
|
||||||
onSave={updateProduct}
|
sortedFiltered.map((product) => (
|
||||||
onDelete={deleteProduct}
|
<ProductRow
|
||||||
/>
|
key={product.id}
|
||||||
))}
|
product={product}
|
||||||
|
categories={categoryOptions}
|
||||||
|
onSave={updateProduct}
|
||||||
|
onDelete={deleteProduct}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Dialog open={scannerOpen} onClose={() => setScannerOpen(false)} fullWidth>
|
<Dialog open={scannerOpen} onClose={() => setScannerOpen(false)} fullWidth>
|
||||||
<DialogTitle>Scan Barcode</DialogTitle>
|
<DialogTitle>Scan Barcode</DialogTitle>
|
||||||
|
|||||||
Reference in New Issue
Block a user