Merge branch 'main' into codex/update-read-only-view-in-productrow-a6vr8p

This commit is contained in:
beatz174-bit
2025-11-24 07:38:31 +10:00
committed by GitHub
5 changed files with 440 additions and 70 deletions
+32 -34
View File
@@ -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<ProductFormState>(() => 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<HTMLInputElement>) => {
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 (
<Card variant="outlined" sx={{ mb: 1 }}>
<CardContent>
<CardContent sx={{ p: 1.25, '&:last-child': { pb: 1.25 } }}>
{isEditing ? (
<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
select
label="Category"
@@ -112,15 +123,15 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
value={formState.barcode}
onChange={handleChange('barcode')}
size="small"
error={Boolean(saveError)}
helperText={saveError || undefined}
error={Boolean(fieldErrors.barcode)}
helperText={fieldErrors.barcode || undefined}
InputProps={{
endAdornment: (
<Button
size="small"
onClick={() => {
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
</Button>
)}
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<IconButton aria-label="Save product" onClick={handleSave} disabled={!formState.name} color="primary">
<CheckIcon />
</IconButton>
<IconButton aria-label="Cancel edit" onClick={handleCancel}>
<CloseIcon />
</IconButton>
</Stack>
</Stack>
) : (
<Stack direction="row" justifyContent="space-between" alignItems="center">
@@ -150,27 +169,6 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
</Stack>
)}
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end', pt: 0 }}>
{isEditing ? (
<>
<IconButton aria-label="Save product" onClick={handleSave} disabled={!formState.name} color="primary">
<CheckIcon />
</IconButton>
<IconButton aria-label="Cancel edit" onClick={handleCancel}>
<CloseIcon />
</IconButton>
</>
) : (
<>
<IconButton aria-label={`Edit ${product.name}`} onClick={() => setIsEditing(true)}>
<EditIcon />
</IconButton>
<IconButton aria-label={`Delete ${product.name}`} onClick={() => onDelete(product.id)}>
<DeleteIcon />
</IconButton>
</>
)}
</CardActions>
<Dialog open={isScannerOpen} onClose={() => setIsScannerOpen(false)} fullWidth>
<DialogTitle>Scan Barcode</DialogTitle>
<DialogContent>
+121
View File
@@ -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
View File
@@ -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 <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 }) => ({
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));
}
};
+116
View File
@@ -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(
<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 () => {
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(
<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', () => {
@@ -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();
});
});
+95 -8
View File
@@ -29,10 +29,12 @@ export const ManageProductsScreen = () => {
const categories = useCategories();
const location = useLocation();
const [search, setSearch] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('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 = () => {
<Button component={RouterLink} to="/categories" variant="outlined" sx={{ alignSelf: 'flex-start' }}>
Edit Categories
</Button>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}>
<TextField
placeholder="Search"
value={search}
onChange={(event) => setSearch(event.target.value)}
InputProps={{ startAdornment: <InputAdornment position="start">{<SearchIcon />}</InputAdornment> }}
fullWidth
/>
<TextField
select
label="Category"
value={selectedCategory}
onChange={(event) => setSelectedCategory(event.target.value)}
sx={{ minWidth: { sm: 180 } }}
>
<MenuItem value="all">All categories</MenuItem>
{categoryOptions.map((cat) => (
<MenuItem key={cat} value={cat}>
{cat}
</MenuItem>
))}
</TextField>
</Stack>
<Stack spacing={1}>
<Typography variant="subtitle1">Add Product</Typography>
<TextField
label="Name"
value={name}
onChange={(event) => setName(event.target.value)}
onChange={(event) => {
setName(event.target.value);
setNameError('');
}}
error={Boolean(nameError)}
helperText={nameError || undefined}
InputProps={
name
? {
endAdornment: (
<Button onClick={() => setName('')} size="small">
<Button
onClick={() => {
setName('');
setNameError('');
}}
size="small"
>
Clear
</Button>
),
@@ -319,7 +400,12 @@ export const ManageProductsScreen = () => {
Save Product
</Button>
</Stack>
{filtered.map((product) => (
{sortedFiltered.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No products match your search and category filter.
</Typography>
) : (
sortedFiltered.map((product) => (
<ProductRow
key={product.id}
product={product}
@@ -327,7 +413,8 @@ export const ManageProductsScreen = () => {
onSave={updateProduct}
onDelete={deleteProduct}
/>
))}
))
)}
</Stack>
<Dialog open={scannerOpen} onClose={() => setScannerOpen(false)} fullWidth>
<DialogTitle>Scan Barcode</DialogTitle>