Merge branch 'main' into codex/increase-unit-test-coverage-and-refactor-utilities

This commit is contained in:
beatz174-bit
2025-12-01 16:08:47 +10:00
committed by GitHub
4 changed files with 445 additions and 260 deletions
+7
View File
@@ -24,6 +24,13 @@
"env": {
"CHROME_PATH": "<path to flatpak chrome wrapper>"
}
},
{
"name": "Unit Test Coverage",
"type": "node-terminal",
"request": "launch",
"command": "npm run test:coverage",
"cwd": "${workspaceFolder}"
}
]
}
+339
View File
@@ -0,0 +1,339 @@
// src/components/AddProductDialog.tsx
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
AlertColor,
Button,
Dialog,
DialogContent,
DialogTitle,
IconButton,
MenuItem,
Stack,
TextField,
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { v4 as uuidv4 } from 'uuid';
import { useDatabase } from '../context/DBProvider';
import { BarcodeScannerView } from './BarcodeScannerView';
import { ExternalProductInfo, fetchProductFromOFF } from '../modules/openFoodFacts';
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE, Product } from '../models/Product';
export type AddProductDialogProps = {
open: boolean;
onClose: () => void;
categoryOptions: string[];
onFeedback?: (feedback: { text: string; severity: AlertColor }) => void;
initialBarcode?: string | null;
};
export const AddProductDialog = ({
open,
onClose,
categoryOptions,
onFeedback,
initialBarcode,
}: AddProductDialogProps) => {
const db = useDatabase();
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');
const [externalProduct, setExternalProduct] = useState<ExternalProductInfo | null>(null);
const resetForm = useCallback(() => {
setName('');
setCategory('');
setBarcode('');
setBarcodeError('');
setNameError('');
setLookupStatus('idle');
setExternalProduct(null);
setScannerOpen(false);
}, []);
useEffect(() => {
if (open) {
if (categoryOptions.length > 0 && !categoryOptions.includes(category)) {
setCategory(categoryOptions[0]);
}
if (initialBarcode) {
setBarcode(initialBarcode);
void lookupBarcode(initialBarcode);
}
} else {
resetForm();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, categoryOptions, initialBarcode]);
useEffect(() => {
if (!barcode) {
setLookupStatus('idle');
setExternalProduct(null);
}
setBarcodeError('');
}, [barcode]);
const categoryMap = useMemo(() => new Map(categoryOptions.map((c) => [c, c])), [categoryOptions]);
const findBarcodeConflict = useCallback(
async (value?: string) => {
if (!value) return undefined;
const conflict = await db.products.where('barcode').equals(value).first();
return conflict ?? undefined;
},
[db.products],
);
const findNameConflict = useCallback(
async (value?: string) => {
if (!value) return undefined;
const normalizedValue = value.trim().toLowerCase();
const conflict = await db.products.filter((product) => product.name.trim().toLowerCase() === normalizedValue).first();
return conflict ?? undefined;
},
[db.products],
);
const assertUniqueBarcode = useCallback(
async (value?: string) => {
if (!value) return;
const conflict = await findBarcodeConflict(value);
if (conflict) {
const error = new Error('This barcode is already assigned to another product.');
error.name = 'DuplicateBarcodeError';
throw error;
}
},
[findBarcodeConflict],
);
const assertUniqueName = useCallback(
async (value: string) => {
const normalized = value.trim().toLowerCase();
if (!normalized) return;
const conflict = await findNameConflict(value);
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();
const categoriesAll = await db.categories.toArray();
const categoriesByName = new Map(categoriesAll.map((c) => [c.name, c.id]));
const eligibleLists = pickLists.filter((pickList) =>
pickList.auto_add_new_products && Array.isArray(pickList.categories)
? pickList.categories.some((catRef: string) => {
if (catRef === product.category) return true;
const resolvedId = categoriesByName.get(catRef);
return resolvedId === product.category;
})
: false,
);
if (eligibleLists.length === 0) return;
await Promise.all(
eligibleLists.map(async (pickList) => {
const existing = await db.pickItems
.where('pick_list_id')
.equals(pickList.id)
.filter((item) => item.product_id === product.id)
.first();
if (existing) return undefined;
return db.pickItems.add({
id: uuidv4(),
pick_list_id: pickList.id,
product_id: product.id,
quantity: 1,
is_carton: false,
status: 'pending',
created_at: timestamp,
updated_at: timestamp,
});
}),
);
},
[db.pickItems, db.pickLists, db.categories],
);
async function lookupBarcode(code: string) {
if (!code) return;
if (typeof navigator !== 'undefined' && 'onLine' in navigator && !navigator.onLine) {
setLookupStatus('offline');
setExternalProduct(null);
return;
}
setLookupStatus('loading');
const result = await fetchProductFromOFF(code);
if (result) {
setExternalProduct(result);
setLookupStatus('found');
if (result.name) {
setName(result.name || '');
}
} else {
setExternalProduct(null);
setLookupStatus('notfound');
}
}
const handleSubmit = async () => {
setNameError('');
setBarcodeError('');
if (!name || !category) {
onFeedback?.({ text: 'Name and category are required.', severity: 'error' });
return;
}
const timestamp = Date.now();
const productId = uuidv4();
try {
await assertUniqueName(name);
await assertUniqueBarcode(barcode);
await db.transaction('rw', db.categories, db.products, db.pickLists, db.pickItems, async () => {
let categoryIdToSave: string;
const existingCategory = await db.categories.where('name').equals(category).first();
if (existingCategory) {
categoryIdToSave = existingCategory.id;
} else {
const newCatId = uuidv4();
const now = Date.now();
await db.categories.add({ id: newCatId, name: category, created_at: now, updated_at: now });
categoryIdToSave = newCatId;
}
const newProduct: Product = {
id: productId,
name: name.trim(),
category: categoryIdToSave,
unit_type: DEFAULT_UNIT_TYPE,
bulk_name: DEFAULT_BULK_NAME,
barcode: barcode || undefined,
archived: false,
created_at: timestamp,
updated_at: timestamp,
};
await db.products.add(newProduct);
await addProductToAutoLists(newProduct, timestamp);
});
onFeedback?.({ text: 'Product added.', severity: 'success' });
resetForm();
onClose();
} catch (err: any) {
if (err?.name === 'DuplicateNameError') {
setNameError(err.message || 'A product with this name already exists.');
return;
}
if (err?.name === 'DuplicateBarcodeError') {
setBarcodeError(err.message || 'This barcode is already assigned to another product.');
return;
}
onFeedback?.({ text: `Failed to add product: ${err?.message ?? String(err)}`, severity: 'error' });
}
};
const handleDialogClose = () => {
resetForm();
onClose();
};
return (
<Dialog open={open} onClose={handleDialogClose} aria-label="Add product dialog" fullWidth maxWidth="sm">
<DialogTitle sx={{ pr: 6 }}>
Add product
<IconButton
aria-label="Close add product"
onClick={handleDialogClose}
sx={{ position: 'absolute', right: 8, top: 8 }}
size="large"
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>
<Stack spacing={2} mt={1}>
<TextField
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
error={!!nameError}
helperText={nameError || ' '}
fullWidth
data-testid="select-add-product-category"
/>
<TextField label="Category" value={category} onChange={(e) => setCategory(e.target.value)} select fullWidth>
{categoryOptions.map((opt) => (
<MenuItem key={opt} value={categoryMap.get(opt) ?? opt}>
{opt}
</MenuItem>
))}
</TextField>
<Stack direction="row" spacing={1} alignItems="center">
<TextField
label="Barcode"
value={barcode}
onChange={(e) => setBarcode(e.target.value)}
inputProps={{ 'data-testid': 'product-barcode-input' }}
error={!!barcodeError}
helperText={barcodeError || ' '}
fullWidth
/>
<Button onClick={() => setScannerOpen(true)}>Scan barcode</Button>
</Stack>
{lookupStatus === 'offline' ? (
<Alert severity="warning" data-testid="barcode-offline">
You are offline. Enter details manually.
</Alert>
) : null}
<Stack direction="row" spacing={1} justifyContent="flex-end">
<Button onClick={handleDialogClose} variant="outlined">
Cancel
</Button>
<Button variant="contained" onClick={handleSubmit} disabled={!name || !category}>
Save product
</Button>
</Stack>
</Stack>
</DialogContent>
<Dialog open={scannerOpen} onClose={() => setScannerOpen(false)} aria-label="Scan barcode">
<DialogTitle>Scan barcode</DialogTitle>
<DialogContent>
<BarcodeScannerView
onDetected={async (code) => {
setScannerOpen(false);
setBarcode(code);
try {
await lookupBarcode(code);
} catch {
// lookupBarcode handles errors
}
}}
/>
</DialogContent>
</Dialog>
</Dialog>
);
};
export default AddProductDialog;
+50
View File
@@ -131,6 +131,14 @@ beforeEach(() => {
mockDb.categories.toArray.mockResolvedValue([]);
mockDb.categories.where.mockImplementation(() => ({ equals: () => ({ first: async () => undefined }) }));
mockDb.products.filter.mockImplementation((predicate?: (product: any) => boolean) => ({
first: async () => {
const items = mockUseProducts();
return predicate ? items.find((item: any) => predicate(item)) : undefined;
},
delete: vi.fn(),
}));
});
function findSaveButton() {
@@ -145,6 +153,10 @@ function findSaveButton() {
return allButtons.length ? allButtons[0] : null;
}
async function openAddProductDialog(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: /add product/i }));
}
describe('ManageProductsScreen barcode lookup', () => {
beforeEach(() => {
mockUseProducts.mockReturnValue([]);
@@ -170,6 +182,8 @@ describe('ManageProductsScreen barcode lookup', () => {
</MemoryRouter>,
);
await openAddProductDialog(user);
await user.click(screen.getByRole('button', { name: /scan barcode/i }));
await user.click(screen.getByRole('button', { name: /mock scan/i }));
@@ -200,6 +214,8 @@ describe('ManageProductsScreen barcode lookup', () => {
</MemoryRouter>,
);
await openAddProductDialog(user);
await user.type(screen.getByLabelText(/name/i), 'New Product');
await user.click(screen.getByRole('button', { name: /scan barcode/i }));
await user.click(screen.getByRole('button', { name: /mock scan/i }));
@@ -235,6 +251,8 @@ describe('ManageProductsScreen barcode lookup', () => {
</MemoryRouter>,
);
await openAddProductDialog(user);
await user.type(screen.getByLabelText(/name/i), 'existing product');
const saveBtn = findSaveButton();
@@ -260,6 +278,8 @@ describe('ManageProductsScreen barcode lookup', () => {
</MemoryRouter>,
);
await openAddProductDialog(user);
await user.click(screen.getByRole('button', { name: /scan barcode/i }));
await user.click(screen.getByRole('button', { name: /mock scan/i }));
@@ -272,6 +292,34 @@ describe('ManageProductsScreen barcode lookup', () => {
}
});
it('closes the add product dialog with the close icon and backdrop', async () => {
mockUseProducts.mockReturnValue([]);
mockUseCategories.mockReturnValue([{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }]);
const user = userEvent.setup();
render(
<MemoryRouter>
<ManageProductsScreen />
</MemoryRouter>,
);
await openAddProductDialog(user);
await user.click(screen.getByRole('button', { name: /close add product/i }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: /add product dialog/i })).not.toBeInTheDocument();
});
await openAddProductDialog(user);
const backdrop = document.querySelector('[role="presentation"]');
expect(backdrop).toBeTruthy();
await user.click(backdrop as HTMLElement);
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: /add product dialog/i })).not.toBeInTheDocument();
});
});
it('prevents updating a product to use an existing barcode', async () => {
mockUseProducts.mockReturnValue([
{
@@ -438,6 +486,8 @@ describe('ManageProductsScreen auto-adding products to pick lists', () => {
</MemoryRouter>,
);
await openAddProductDialog(user);
await user.type(screen.getByLabelText(/name/i), 'Granola Bar');
const saveBtn = findSaveButton();
expect(saveBtn).toBeTruthy();
+49 -260
View File
@@ -4,9 +4,6 @@ import {
AlertColor,
Button,
Container,
Dialog,
DialogContent,
DialogTitle,
InputAdornment,
MenuItem,
Snackbar,
@@ -21,10 +18,8 @@ import { v4 as uuidv4 } from 'uuid';
import { ProductRow } from '../components/ProductRow';
import { useCategories, useProducts } from '../hooks/dataHooks';
import { useDatabase } from '../context/DBProvider';
import { BarcodeScannerView } from '../components/BarcodeScannerView';
import { ExternalProductInfo, fetchProductFromOFF } from '../modules/openFoodFacts';
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE, Product } from '../models/Product';
import { isOnline } from '../platform/web';
import { AddProductDialog } from '../components/AddProductDialog';
const ManageProductsScreen = () => {
const db = useDatabase();
@@ -33,15 +28,9 @@ const ManageProductsScreen = () => {
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');
const [externalProduct, setExternalProduct] = useState<ExternalProductInfo | null>(null);
const [feedback, setFeedback] = useState<{ text: string; severity: AlertColor } | null>(null);
const [addProductDialogOpen, setAddProductDialogOpen] = useState(false);
const [pendingBarcode, setPendingBarcode] = useState<string | null>(null);
// Map category id -> name
const categoriesById = useMemo(() => new Map(categories.map((c) => [c.id, c.name])), [categories]);
@@ -95,55 +84,6 @@ const ManageProductsScreen = () => {
[findNameConflict],
);
const addProductToAutoLists = useCallback(
async (product: Product, timestamp: number) => {
const pickLists = await db.pickLists.toArray();
const categoriesAll = await db.categories.toArray();
const categoriesByName = new Map(categoriesAll.map((c) => [c.name, c.id]));
const eligibleLists = pickLists.filter((pickList) =>
pickList.auto_add_new_products && Array.isArray(pickList.categories)
? pickList.categories.some((catRef: string) => {
// catRef can be an id or a name — resolve both
if (catRef === product.category) return true;
const resolvedId = categoriesByName.get(catRef);
return resolvedId === product.category;
})
: false,
);
if (eligibleLists.length === 0) return;
await Promise.all(
eligibleLists.map(async (pickList) => {
const existing = await db.pickItems
.where('pick_list_id')
.equals(pickList.id)
.filter((item) => item.product_id === product.id)
.first();
if (existing) return undefined;
return db.pickItems.add({
id: uuidv4(),
pick_list_id: pickList.id,
product_id: product.id,
quantity: 1,
is_carton: false,
status: 'pending',
created_at: timestamp,
updated_at: timestamp,
});
}),
);
},
[db.pickItems, db.pickLists, db.categories],
);
useEffect(() => {
if (categoryOptions.length === 0) return;
if (!categoryOptions.includes(category)) {
setCategory(categoryOptions[0]);
}
}, [category, categoryOptions]);
useEffect(() => {
if (selectedCategory !== 'all' && !categoryOptions.includes(selectedCategory)) {
setSelectedCategory('all');
@@ -169,113 +109,12 @@ const ManageProductsScreen = () => {
useEffect(() => {
const state = location.state as { newBarcode?: string } | null;
if (state?.newBarcode) {
setBarcode(state.newBarcode);
void lookupBarcode(state.newBarcode);
setPendingBarcode(state.newBarcode);
setAddProductDialogOpen(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.state]);
useEffect(() => {
if (!barcode) {
setLookupStatus('idle');
setExternalProduct(null);
}
setBarcodeError('');
}, [barcode]);
async function lookupBarcode(code: string) {
if (!code) return;
if (!isOnline()) {
setLookupStatus('offline');
setExternalProduct(null);
return;
}
setLookupStatus('loading');
const result = await fetchProductFromOFF(code);
if (result) {
setExternalProduct(result);
setLookupStatus('found');
// TEST-FRIENDLY CHANGE: always set the name when a result is found
if (result.name) {
setName(result.name || '');
}
} else {
setExternalProduct(null);
setLookupStatus('notfound');
}
}
const addProduct = async () => {
setNameError('');
setBarcodeError('');
if (!name || !category) {
setFeedback({ text: 'Name and category are required.', severity: 'error' });
return;
}
const timestamp = Date.now();
const productId = uuidv4();
try {
await assertUniqueName(name);
await assertUniqueBarcode(barcode);
await db.transaction(
'rw',
db.categories,
db.products,
db.pickLists,
db.pickItems,
async () => {
let categoryIdToSave: string;
const existingCategory = await db.categories.where('name').equals(category).first();
if (existingCategory) {
categoryIdToSave = existingCategory.id;
} else {
const newCatId = uuidv4();
const now = Date.now();
await db.categories.add({ id: newCatId, name: category, created_at: now, updated_at: now });
categoryIdToSave = newCatId;
}
const newProduct: Product = {
id: productId,
name: name.trim(),
category: categoryIdToSave,
unit_type: DEFAULT_UNIT_TYPE,
bulk_name: DEFAULT_BULK_NAME,
barcode: barcode || undefined,
archived: false,
created_at: timestamp,
updated_at: timestamp,
};
await db.products.add(newProduct);
await addProductToAutoLists(newProduct, timestamp);
},
);
setName('');
setBarcode('');
setNameError('');
setBarcodeError('');
setFeedback({ text: 'Product added.', severity: 'success' });
} catch (err: any) {
console.error('Failed to add product', err);
if (err?.name === 'DuplicateNameError') {
setNameError(err.message || 'A product with this name already exists.');
return;
}
if (err?.name === 'DuplicateBarcodeError') {
setBarcodeError(err.message || 'This barcode is already assigned to another product.');
return;
}
setFeedback({ text: `Failed to add product: ${err?.message ?? String(err)}`, severity: 'error' });
}
};
const updateProduct = async (
productId: string,
updates: {
@@ -284,9 +123,6 @@ const ManageProductsScreen = () => {
barcode?: string;
},
) => {
setNameError('');
setBarcodeError('');
try {
await assertUniqueName(updates.name, productId);
await assertUniqueBarcode(updates.barcode, productId);
@@ -339,13 +175,7 @@ const ManageProductsScreen = () => {
} catch (err: any) {
console.error('Failed to update product', err);
if (err?.name === 'DuplicateNameError') {
// keep parent-level state for visibility, but re-throw so ProductRow can set field errors
setNameError(err.message || 'A product with this name already exists.');
throw err;
}
if (err?.name === 'DuplicateBarcodeError') {
setBarcodeError(err.message || 'This barcode is already assigned to another product.');
if (err?.name === 'DuplicateNameError' || err?.name === 'DuplicateBarcodeError') {
throw err;
}
@@ -364,96 +194,64 @@ const ManageProductsScreen = () => {
setFeedback({ text: 'Product deleted.', severity: 'success' });
};
const handleAddProductClose = () => {
setAddProductDialogOpen(false);
setPendingBarcode(null);
};
// ---------- RENDER ----------
return (
<Container sx={{ py: 4 }}>
<Stack spacing={2} mb={2}>
<Typography variant="h5">Manage Products</Typography>
<Stack spacing={1}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={2}
justifyContent="space-between"
alignItems={{ xs: 'stretch', sm: 'center' }}
>
<Button component={RouterLink} to="/categories" variant="outlined">
Edit Categories
</Button>
<Stack direction="row" spacing={2}>
<TextField
placeholder="Search"
value={search}
onChange={(e) => setSearch(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
fullWidth
/>
<TextField
select
label="Filter by category"
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
sx={{ minWidth: 200 }}
data-testid="select-filter-by-category"
>
<MenuItem value="all">All categories</MenuItem>
{categoryOptions.map((opt) => (
<MenuItem key={opt} value={opt}>
{opt}
</MenuItem>
))}
</TextField>
</Stack>
<Button
variant="contained"
onClick={() => setAddProductDialogOpen(true)}
sx={{ alignSelf: { xs: 'stretch', sm: 'flex-start' } }}
>
Add product
</Button>
</Stack>
<Stack spacing={1}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<TextField
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
error={!!nameError}
data-testid="select-add-product-category"
placeholder="Search"
value={search}
onChange={(e) => setSearch(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
fullWidth
/>
{nameError ? <div data-testid="name-error">{nameError}</div> : null}
<TextField
label="Category"
value={category}
onChange={(e) => setCategory(e.target.value)}
select
label="Filter by category"
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
sx={{ minWidth: 200 }}
data-testid="select-filter-by-category"
>
<MenuItem value="all">All categories</MenuItem>
{categoryOptions.map((opt) => (
<MenuItem key={opt} value={opt}>
{opt}
</MenuItem>
))}
</TextField>
<Stack direction="row" spacing={1} alignItems="center">
<TextField
label="Barcode"
value={barcode}
onChange={(e) => setBarcode(e.target.value)}
inputProps={{ 'data-testid': 'product-barcode-input' }}
error={!!barcodeError}
/>
<Button onClick={() => setScannerOpen(true)}>Scan barcode</Button>
</Stack>
{barcodeError ? <div data-testid="barcode-error">{barcodeError}</div> : null}
{lookupStatus === 'offline' ? (
<Alert severity="warning" data-testid="barcode-offline">
You are offline. Enter details manually.
</Alert>
) : null}
<Stack direction="row" spacing={1}>
<Button variant="contained" onClick={addProduct} disabled={!name || !category}>
Save product
</Button>
</Stack>
</Stack>
</Stack>
@@ -470,22 +268,13 @@ const ManageProductsScreen = () => {
))}
</div>
<Dialog open={scannerOpen} onClose={() => setScannerOpen(false)} aria-label="Scan barcode">
<DialogTitle>Scan barcode</DialogTitle>
<DialogContent>
<BarcodeScannerView
onDetected={async (code) => {
setScannerOpen(false);
setBarcode(code);
try {
await lookupBarcode(code);
} catch {
// lookupBarcode handles errors
}
}}
/>
</DialogContent>
</Dialog>
<AddProductDialog
open={addProductDialogOpen}
onClose={handleAddProductClose}
categoryOptions={categoryOptions}
onFeedback={setFeedback}
initialBarcode={pendingBarcode}
/>
<Snackbar open={!!feedback} autoHideDuration={3000} onClose={() => setFeedback(null)}>
{feedback ? <Alert severity={feedback.severity}>{feedback.text}</Alert> : undefined}