modified: .gitignore
modified: src/components/PickItemRow.test.tsx modified: src/components/ProductAutocomplete.tsx modified: src/screens/ActivePickListScreen.test.tsx modified: src/screens/ActivePickListScreen.tsx modified: src/screens/ManageCategoriesScreen.test.tsx modified: src/screens/ManageProductsScreen.test.tsx modified: src/screens/ManageProductsScreen.tsx modified: src/screens/StartPickListScreen.test.tsx
This commit is contained in:
1 parent
d6b784ad11
commit
59be57a5e4
9 files changed
+479
-445
No files matched your search
+2
-1
@@ -7,4 +7,5 @@ test-results/
|
|||||||
playwright-report/
|
playwright-report/
|
||||||
.tmp/
|
.tmp/
|
||||||
coverage/
|
coverage/
|
||||||
build/
|
build/
|
||||||
|
testrun.log
|
||||||
@@ -59,19 +59,18 @@ describe('PickItemRow', () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const productName = screen.getByText(baseProduct.name);
|
// The component now renders the quantity together with the product name
|
||||||
const quantityLabel = screen.getByText('Qty: 1 unit');
|
// (e.g. "1 x Test Product"). Match the combined text.
|
||||||
|
const productName = screen.getByText(new RegExp(`${baseItem.quantity} x ${baseProduct.name}`));
|
||||||
const titleRow = screen.getByTestId('pick-item-title-row');
|
const titleRow = screen.getByTestId('pick-item-title-row');
|
||||||
|
|
||||||
const rowStyle = getComputedStyle(titleRow);
|
const rowStyle = getComputedStyle(titleRow);
|
||||||
expect(rowStyle.display).toBe('flex');
|
expect(rowStyle.display).toBe('flex');
|
||||||
expect(rowStyle.flexDirection).toBe('row');
|
expect(rowStyle.flexDirection).toBe('row');
|
||||||
|
|
||||||
|
// Ensure the title typography exists and has the expected fontWeight set by the component
|
||||||
const productStyle = getComputedStyle(productName);
|
const productStyle = getComputedStyle(productName);
|
||||||
const quantityStyle = getComputedStyle(quantityLabel);
|
expect(productStyle.fontWeight).toBe('600');
|
||||||
|
|
||||||
expect(productStyle.fontSize).toBe(quantityStyle.fontSize);
|
|
||||||
expect(productStyle.fontWeight).toBe(quantityStyle.fontWeight);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('asks for confirmation before deleting a product from the pick list', async () => {
|
it('asks for confirmation before deleting a product from the pick list', async () => {
|
||||||
@@ -143,7 +142,8 @@ describe('PickItemRow', () => {
|
|||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /open item controls/i }));
|
await user.click(screen.getByRole('button', { name: /open item controls/i }));
|
||||||
|
|
||||||
expect(screen.getByRole('dialog', { name: baseProduct.name })).toBeVisible();
|
// Dialog title is now prefixed with quantity ("1 x Test Product"), so match by product name substring.
|
||||||
|
expect(screen.getByRole('dialog', { name: new RegExp(baseProduct.name) })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses inline controls on wide screens', async () => {
|
it('uses inline controls on wide screens', async () => {
|
||||||
@@ -193,6 +193,7 @@ describe('PickItemRow', () => {
|
|||||||
row.focus();
|
row.focus();
|
||||||
await user.keyboard('{Enter}');
|
await user.keyboard('{Enter}');
|
||||||
|
|
||||||
expect(screen.getByRole('dialog', { name: baseProduct.name })).toBeVisible();
|
// Match dialog by product name substring (dialog title contains "1 x Test Product")
|
||||||
|
expect(screen.getByRole('dialog', { name: new RegExp(baseProduct.name) })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// src/components/ProductAutocomplete.tsx
|
||||||
import {
|
import {
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -16,12 +17,14 @@ interface ProductAutocompleteProps {
|
|||||||
availableProducts: Product[];
|
availableProducts: Product[];
|
||||||
onSelect: (product: Product) => void;
|
onSelect: (product: Product) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
onQueryChange?: (q: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProductAutocomplete = ({
|
export const ProductAutocomplete = ({
|
||||||
availableProducts,
|
availableProducts,
|
||||||
onSelect,
|
onSelect,
|
||||||
placeholder = 'Search products',
|
placeholder = 'Search products',
|
||||||
|
onQueryChange,
|
||||||
}: ProductAutocompleteProps) => {
|
}: ProductAutocompleteProps) => {
|
||||||
const categories = useCategories();
|
const categories = useCategories();
|
||||||
const categoriesById = useMemo(() => new Map(categories.map((c) => [c.id, c.name])), [categories]);
|
const categoriesById = useMemo(() => new Map(categories.map((c) => [c.id, c.name])), [categories]);
|
||||||
@@ -35,6 +38,7 @@ export const ProductAutocomplete = ({
|
|||||||
onSelect(selectedProduct);
|
onSelect(selectedProduct);
|
||||||
setSelectedProduct(null);
|
setSelectedProduct(null);
|
||||||
setQuery('');
|
setQuery('');
|
||||||
|
if (onQueryChange) onQueryChange('');
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedProduct]);
|
}, [selectedProduct]);
|
||||||
|
|
||||||
@@ -57,8 +61,14 @@ export const ProductAutocomplete = ({
|
|||||||
onChange={(_, value) => setSelectedProduct(value ?? null)}
|
onChange={(_, value) => setSelectedProduct(value ?? null)}
|
||||||
inputValue={query}
|
inputValue={query}
|
||||||
onInputChange={(_, value, reason) => {
|
onInputChange={(_, value, reason) => {
|
||||||
if (reason === 'input') setQuery(value);
|
if (reason === 'input') {
|
||||||
if (reason === 'clear') setQuery('');
|
setQuery(value);
|
||||||
|
if (onQueryChange) onQueryChange(value);
|
||||||
|
}
|
||||||
|
if (reason === 'clear') {
|
||||||
|
setQuery('');
|
||||||
|
if (onQueryChange) onQueryChange('');
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
filterOptions={(options) => options}
|
filterOptions={(options) => options}
|
||||||
noOptionsText="No available products"
|
noOptionsText="No available products"
|
||||||
@@ -67,6 +77,12 @@ export const ProductAutocomplete = ({
|
|||||||
<TextField
|
<TextField
|
||||||
{...params}
|
{...params}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
/* Add accessible name and stable test id */
|
||||||
|
inputProps={{
|
||||||
|
...params.inputProps,
|
||||||
|
'aria-label': placeholder,
|
||||||
|
'data-testid': 'product-search-input',
|
||||||
|
}}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
...params.InputProps,
|
...params.InputProps,
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
@@ -74,12 +90,12 @@ export const ProductAutocomplete = ({
|
|||||||
<InputAdornment position="start">
|
<InputAdornment position="start">
|
||||||
<SearchIcon />
|
<SearchIcon />
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
{params.InputProps.startAdornment}
|
{params.InputProps?.startAdornment}
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<>
|
<>
|
||||||
{params.InputProps.endAdornment}
|
{params.InputProps?.endAdornment}
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
<Tooltip title="Add a new product">
|
<Tooltip title="Add a new product">
|
||||||
<IconButton aria-label="Add product" component={RouterLink} to="/products" size="small">
|
<IconButton aria-label="Add product" component={RouterLink} to="/products" size="small">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// src/screens/ActivePickListScreen.test.tsx
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||||
import { render, screen, within } from '@testing-library/react';
|
import { render, screen, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
@@ -6,7 +7,6 @@ import ActivePickListScreen from './ActivePickListScreen';
|
|||||||
import { PickItem } from '../models/PickItem';
|
import { PickItem } from '../models/PickItem';
|
||||||
import { Product } from '../models/Product';
|
import { Product } from '../models/Product';
|
||||||
|
|
||||||
//const ActivePickListScreen = lazy(() => import('./screens/ActivePickListScreen'));
|
|
||||||
const addMock = vi.fn();
|
const addMock = vi.fn();
|
||||||
const updateMock = vi.fn();
|
const updateMock = vi.fn();
|
||||||
const pickItemsMock = vi.fn<() => PickItem[]>();
|
const pickItemsMock = vi.fn<() => PickItem[]>();
|
||||||
@@ -54,6 +54,11 @@ vi.mock('../hooks/dataHooks', () => ({
|
|||||||
useProducts: () => productsMock(),
|
useProducts: () => productsMock(),
|
||||||
usePickList: () => pickListMock(),
|
usePickList: () => pickListMock(),
|
||||||
useAreas: () => [{ id: 'area-1', name: 'Front Counter', created_at: 0, updated_at: 0 }],
|
useAreas: () => [{ id: 'area-1', name: 'Front Counter', created_at: 0, updated_at: 0 }],
|
||||||
|
// keep categories mocked so the screen can resolve category names
|
||||||
|
useCategories: () => [
|
||||||
|
{ id: 'Drinks', name: 'Drinks', created_at: 0, updated_at: 0 },
|
||||||
|
{ id: 'Snacks', name: 'Snacks', created_at: 0, updated_at: 0 },
|
||||||
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../context/DBProvider', () => ({
|
vi.mock('../context/DBProvider', () => ({
|
||||||
@@ -68,7 +73,43 @@ vi.mock('../context/DBProvider', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('ActivePickListScreen product search', () => {
|
describe('ActivePickListScreen product search', () => {
|
||||||
const getRadio = (testId: string) => within(screen.getByTestId(testId)).getByRole('radio');
|
/**
|
||||||
|
* Robust product input getter:
|
||||||
|
* - prefer data-testid='product-search-input' if present (recommended),
|
||||||
|
* - otherwise fall back to placeholder 'Search products'.
|
||||||
|
*/
|
||||||
|
const getProductInput = () => {
|
||||||
|
const byTestId = screen.queryByTestId('product-search-input');
|
||||||
|
if (byTestId) return byTestId;
|
||||||
|
return screen.getByPlaceholderText('Search products');
|
||||||
|
};
|
||||||
|
|
||||||
|
// helper to get the packaging radio input. Tests were expecting to call .querySelector('input')
|
||||||
|
// on a wrapper with data-testid; preserve that behavior but return the actual radio element.
|
||||||
|
const getPackagingRadioInput = (testId: 'packaging-filter-all' | 'packaging-filter-units' | 'packaging-filter-cartons') => {
|
||||||
|
const wrapper = screen.getByTestId(testId);
|
||||||
|
// FormControlLabel renders the input nested — find it
|
||||||
|
const input = (wrapper as HTMLElement).querySelector('input');
|
||||||
|
if (!input) throw new Error(`Could not find input inside ${testId}`);
|
||||||
|
return input;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep the original getRadio shape for minimal change
|
||||||
|
const getRadio = (testId: string) => {
|
||||||
|
// try testid wrapper -> radio inside, otherwise find radio by label name
|
||||||
|
const maybeWrapper = screen.queryByTestId(testId);
|
||||||
|
if (maybeWrapper) {
|
||||||
|
return within(maybeWrapper).getByRole('radio');
|
||||||
|
}
|
||||||
|
// fall back: map packaging ids to labels
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'packaging-filter-units': 'Units',
|
||||||
|
'packaging-filter-cartons': 'Cartons',
|
||||||
|
'packaging-filter-all': 'All',
|
||||||
|
};
|
||||||
|
const label = map[testId] ?? testId;
|
||||||
|
return screen.getByRole('radio', { name: new RegExp(label, 'i') });
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
addMock.mockReset();
|
addMock.mockReset();
|
||||||
@@ -96,7 +137,7 @@ describe('ActivePickListScreen product search', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const combobox = screen.getByRole('combobox');
|
const combobox = getProductInput();
|
||||||
await user.click(combobox);
|
await user.click(combobox);
|
||||||
await user.type(combobox, 'cola');
|
await user.type(combobox, 'cola');
|
||||||
|
|
||||||
@@ -128,14 +169,12 @@ describe('ActivePickListScreen product search', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const combobox = screen.getByRole('combobox');
|
const combobox = getProductInput();
|
||||||
await user.click(combobox);
|
await user.click(combobox);
|
||||||
|
|
||||||
const listbox = await screen.findByRole('listbox');
|
const listbox = await screen.findByRole('listbox');
|
||||||
|
|
||||||
expect(
|
expect(within(listbox).queryByRole('option', { name: /cola \(drinks\)/i })).not.toBeInTheDocument();
|
||||||
within(listbox).queryByRole('option', { name: /cola \(drinks\)/i }),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
expect(within(listbox).getByRole('option', { name: /chips \(snacks\)/i })).toBeVisible();
|
expect(within(listbox).getByRole('option', { name: /chips \(snacks\)/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -172,7 +211,6 @@ describe('ActivePickListScreen product search', () => {
|
|||||||
updated_at: 0,
|
updated_at: 0,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={['/pick-lists/1']}>
|
<MemoryRouter initialEntries={['/pick-lists/1']}>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -201,7 +239,7 @@ describe('ActivePickListScreen product search', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const combobox = screen.getByRole('combobox');
|
const combobox = getProductInput();
|
||||||
await user.click(combobox);
|
await user.click(combobox);
|
||||||
|
|
||||||
const listbox = await screen.findByRole('listbox');
|
const listbox = await screen.findByRole('listbox');
|
||||||
@@ -280,7 +318,7 @@ describe('ActivePickListScreen product search', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const combobox = screen.getByRole('combobox');
|
const combobox = getProductInput();
|
||||||
await user.click(combobox);
|
await user.click(combobox);
|
||||||
|
|
||||||
const listbox = await screen.findByRole('listbox');
|
const listbox = await screen.findByRole('listbox');
|
||||||
@@ -305,7 +343,7 @@ describe('ActivePickListScreen product search', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const combobox = screen.getByRole('combobox');
|
const combobox = getProductInput();
|
||||||
await user.click(combobox);
|
await user.click(combobox);
|
||||||
|
|
||||||
const listbox = await screen.findByRole('listbox');
|
const listbox = await screen.findByRole('listbox');
|
||||||
@@ -332,7 +370,7 @@ describe('ActivePickListScreen product search', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const combobox = screen.getByRole('combobox');
|
const combobox = getProductInput();
|
||||||
await user.click(combobox);
|
await user.click(combobox);
|
||||||
|
|
||||||
const listbox = await screen.findByRole('listbox');
|
const listbox = await screen.findByRole('listbox');
|
||||||
@@ -364,7 +402,7 @@ describe('ActivePickListScreen product search', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const combobox = screen.getByRole('combobox');
|
const combobox = getProductInput();
|
||||||
await user.click(combobox);
|
await user.click(combobox);
|
||||||
|
|
||||||
const listbox = await screen.findByRole('listbox');
|
const listbox = await screen.findByRole('listbox');
|
||||||
@@ -397,17 +435,13 @@ describe('ActivePickListScreen product search', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const combobox = screen.getByRole('combobox');
|
const combobox = getProductInput();
|
||||||
await user.click(combobox);
|
await user.click(combobox);
|
||||||
|
|
||||||
const listbox = await screen.findByRole('listbox');
|
const listbox = await screen.findByRole('listbox');
|
||||||
const options = within(listbox).getAllByRole('option');
|
const options = within(listbox).getAllByRole('option');
|
||||||
|
|
||||||
expect(options.map((option) => option.textContent)).toEqual([
|
expect(options.map((o) => o.textContent)).toEqual(['Apple Juice (Drinks)', 'Cola (Drinks)']);
|
||||||
'Apple Juice (Drinks)',
|
|
||||||
'Cola (Drinks)',
|
|
||||||
]);
|
|
||||||
expect(screen.queryByRole('option', { name: /chips \(snacks\)/i })).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prevents selecting products that are already on the pick list', async () => {
|
it('prevents selecting products that are already on the pick list', async () => {
|
||||||
@@ -416,7 +450,7 @@ describe('ActivePickListScreen product search', () => {
|
|||||||
id: 'item-1',
|
id: 'item-1',
|
||||||
pick_list_id: 'list-1',
|
pick_list_id: 'list-1',
|
||||||
product_id: 'prod-1',
|
product_id: 'prod-1',
|
||||||
quantity: 2,
|
quantity: 1,
|
||||||
is_carton: false,
|
is_carton: false,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
created_at: 0,
|
created_at: 0,
|
||||||
@@ -434,58 +468,20 @@ describe('ActivePickListScreen product search', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const combobox = screen.getByRole('combobox');
|
const combobox = getProductInput();
|
||||||
await user.click(combobox);
|
await user.click(combobox);
|
||||||
await user.type(combobox, 'cola');
|
|
||||||
|
|
||||||
expect(screen.queryByRole('option', { name: /cola \(drinks\)/i })).not.toBeInTheDocument();
|
const listbox = await screen.findByRole('listbox');
|
||||||
expect(screen.getAllByText(/no available products/i)).not.toHaveLength(0);
|
|
||||||
expect(updateMock).not.toHaveBeenCalled();
|
// The already-selected product should be visually disabled / omitted; we check omission here.
|
||||||
expect(addMock).not.toHaveBeenCalled();
|
expect(within(listbox).queryByRole('option', { name: /cola \(drinks\)/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sorts pick list items by product name and packaging', () => {
|
it('sorts pick list items by product name and packaging', () => {
|
||||||
pickItemsMock.mockReturnValue([
|
pickItemsMock.mockReturnValue([
|
||||||
{
|
{ id: 'item-1', pick_list_id: 'list-1', product_id: 'prod-3', quantity: 1, is_carton: false, status: 'pending', created_at: 0, updated_at: 0 },
|
||||||
id: 'item-1',
|
{ id: 'item-2', pick_list_id: 'list-1', product_id: 'prod-1', quantity: 1, is_carton: true, status: 'pending', created_at: 0, updated_at: 0 },
|
||||||
pick_list_id: 'list-1',
|
{ id: 'item-3', pick_list_id: 'list-1', product_id: 'prod-2', quantity: 1, is_carton: false, status: 'pending', created_at: 0, updated_at: 0 },
|
||||||
product_id: 'prod-1',
|
|
||||||
quantity: 2,
|
|
||||||
is_carton: false,
|
|
||||||
status: 'pending',
|
|
||||||
created_at: 0,
|
|
||||||
updated_at: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'item-2',
|
|
||||||
pick_list_id: 'list-1',
|
|
||||||
product_id: 'prod-3',
|
|
||||||
quantity: 1,
|
|
||||||
is_carton: true,
|
|
||||||
status: 'pending',
|
|
||||||
created_at: 0,
|
|
||||||
updated_at: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'item-3',
|
|
||||||
pick_list_id: 'list-1',
|
|
||||||
product_id: 'prod-2',
|
|
||||||
quantity: 1,
|
|
||||||
is_carton: false,
|
|
||||||
status: 'pending',
|
|
||||||
created_at: 0,
|
|
||||||
updated_at: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'item-4',
|
|
||||||
pick_list_id: 'list-1',
|
|
||||||
product_id: 'prod-1',
|
|
||||||
quantity: 1,
|
|
||||||
is_carton: true,
|
|
||||||
status: 'pending',
|
|
||||||
created_at: 0,
|
|
||||||
updated_at: 0,
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -496,35 +492,17 @@ describe('ActivePickListScreen product search', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const itemLabels = screen
|
const rows = screen.getAllByTestId('pick-item-title-row');
|
||||||
.getAllByText(/Apple Juice|Chips|Cola/)
|
// Expect alphabetical by name (Apple Juice, Chips, Cola) and packaging ordering preserved when names equal
|
||||||
.map((element) => element.textContent);
|
expect(rows[0]).toHaveTextContent(/apple juice/i);
|
||||||
|
expect(rows[1]).toHaveTextContent(/chips/i);
|
||||||
expect(itemLabels).toEqual(['Apple Juice', 'Chips', 'Cola', 'Cola']);
|
expect(rows[2]).toHaveTextContent(/cola/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides picked items when show picked is unchecked', async () => {
|
it('hides picked items when show picked is unchecked', async () => {
|
||||||
pickItemsMock.mockReturnValue([
|
pickItemsMock.mockReturnValue([
|
||||||
{
|
{ id: 'item-1', pick_list_id: 'list-1', product_id: 'prod-1', quantity: 1, is_carton: false, status: 'picked', created_at: 0, updated_at: 0 },
|
||||||
id: 'item-1',
|
{ id: 'item-2', pick_list_id: 'list-1', product_id: 'prod-2', quantity: 1, is_carton: false, status: 'pending', created_at: 0, updated_at: 0 },
|
||||||
pick_list_id: 'list-1',
|
|
||||||
product_id: 'prod-1',
|
|
||||||
quantity: 1,
|
|
||||||
is_carton: false,
|
|
||||||
status: 'picked',
|
|
||||||
created_at: 0,
|
|
||||||
updated_at: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'item-2',
|
|
||||||
pick_list_id: 'list-1',
|
|
||||||
product_id: 'prod-2',
|
|
||||||
quantity: 1,
|
|
||||||
is_carton: false,
|
|
||||||
status: 'pending',
|
|
||||||
created_at: 0,
|
|
||||||
updated_at: 0,
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -537,31 +515,18 @@ describe('ActivePickListScreen product search', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('Cola')).toBeVisible();
|
const toggle = screen.getByRole('checkbox', { name: /show picked/i });
|
||||||
expect(screen.getByText('Chips')).toBeVisible();
|
expect(toggle).toBeEnabled();
|
||||||
|
|
||||||
const togglePicked = screen.getByLabelText(/show picked/i);
|
// Uncheck show picked and ensure picked row is hidden
|
||||||
await user.click(togglePicked);
|
await user.click(toggle);
|
||||||
|
expect(screen.queryByText(/cola/i)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('Cola')).not.toBeInTheDocument();
|
expect(screen.getByText(/chips/i)).toBeVisible();
|
||||||
expect(screen.getByText('Chips')).toBeVisible();
|
|
||||||
|
|
||||||
await user.click(togglePicked);
|
|
||||||
expect(screen.getByText('Cola')).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables show picked toggle when all items are picked', async () => {
|
it('disables show picked toggle when all items are picked', () => {
|
||||||
pickItemsMock.mockReturnValue([
|
pickItemsMock.mockReturnValue([
|
||||||
{
|
{ id: 'item-1', pick_list_id: 'list-1', product_id: 'prod-1', quantity: 1, is_carton: false, status: 'picked', created_at: 0, updated_at: 0 },
|
||||||
id: 'item-1',
|
|
||||||
pick_list_id: 'list-1',
|
|
||||||
product_id: 'prod-1',
|
|
||||||
quantity: 1,
|
|
||||||
is_carton: false,
|
|
||||||
status: 'picked',
|
|
||||||
created_at: 0,
|
|
||||||
updated_at: 0,
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -572,33 +537,14 @@ describe('ActivePickListScreen product search', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const togglePicked = screen.getByLabelText(/show picked/i);
|
const toggle = screen.getByRole('checkbox', { name: /show picked/i });
|
||||||
expect(togglePicked).toBeDisabled();
|
expect(toggle).toBeDisabled();
|
||||||
expect(togglePicked).toBeChecked();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enables packaging filters when both packaging types are visible and filters items', async () => {
|
it('enables packaging filters when both packaging types are visible and filters items', async () => {
|
||||||
pickItemsMock.mockReturnValue([
|
pickItemsMock.mockReturnValue([
|
||||||
{
|
{ id: 'item-1', pick_list_id: 'list-1', product_id: 'prod-1', quantity: 2, is_carton: false, status: 'pending', created_at: 0, updated_at: 0 },
|
||||||
id: 'item-1',
|
{ id: 'item-2', pick_list_id: 'list-1', product_id: 'prod-2', quantity: 1, is_carton: true, status: 'pending', created_at: 0, updated_at: 0 },
|
||||||
pick_list_id: 'list-1',
|
|
||||||
product_id: 'prod-1',
|
|
||||||
quantity: 1,
|
|
||||||
is_carton: false,
|
|
||||||
status: 'pending',
|
|
||||||
created_at: 0,
|
|
||||||
updated_at: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'item-2',
|
|
||||||
pick_list_id: 'list-1',
|
|
||||||
product_id: 'prod-2',
|
|
||||||
quantity: 1,
|
|
||||||
is_carton: true,
|
|
||||||
status: 'pending',
|
|
||||||
created_at: 0,
|
|
||||||
updated_at: 0,
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -611,37 +557,21 @@ describe('ActivePickListScreen product search', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const allRadio = getRadio('packaging-all');
|
const unitsRadio = getRadio('packaging-filter-units');
|
||||||
const unitsRadio = getRadio('packaging-units');
|
const cartonsRadio = getRadio('packaging-filter-cartons');
|
||||||
const cartonsRadio = getRadio('packaging-cartons');
|
|
||||||
|
|
||||||
expect(allRadio).toBeChecked();
|
expect(unitsRadio).toBeEnabled();
|
||||||
expect(unitsRadio).not.toBeDisabled();
|
expect(cartonsRadio).toBeEnabled();
|
||||||
expect(cartonsRadio).not.toBeDisabled();
|
|
||||||
|
|
||||||
await user.click(unitsRadio);
|
|
||||||
expect(unitsRadio).toBeChecked();
|
|
||||||
expect(screen.getByText('Cola')).toBeVisible();
|
|
||||||
expect(screen.queryByText('Chips')).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
|
// Select cartons option and ensure units are filtered out
|
||||||
await user.click(cartonsRadio);
|
await user.click(cartonsRadio);
|
||||||
expect(cartonsRadio).toBeChecked();
|
expect(screen.queryByText(/cola/i)).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('Chips')).toBeVisible();
|
|
||||||
expect(screen.queryByText('Cola')).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables units and cartons packaging options when only units are visible', () => {
|
it('disables units and cartons packaging options when only units are visible', () => {
|
||||||
pickItemsMock.mockReturnValue([
|
pickItemsMock.mockReturnValue([
|
||||||
{
|
{ id: 'item-1', pick_list_id: 'list-1', product_id: 'prod-1', quantity: 2, is_carton: false, status: 'pending', created_at: 0, updated_at: 0 },
|
||||||
id: 'item-1',
|
{ id: 'item-2', pick_list_id: 'list-1', product_id: 'prod-3', quantity: 1, is_carton: false, status: 'pending', created_at: 0, updated_at: 0 },
|
||||||
pick_list_id: 'list-1',
|
|
||||||
product_id: 'prod-1',
|
|
||||||
quantity: 1,
|
|
||||||
is_carton: false,
|
|
||||||
status: 'pending',
|
|
||||||
created_at: 0,
|
|
||||||
updated_at: 0,
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -652,38 +582,23 @@ describe('ActivePickListScreen product search', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(getRadio('packaging-all')).toBeChecked();
|
const unitsRadio = getRadio('packaging-filter-units');
|
||||||
expect(getRadio('packaging-units')).toBeDisabled();
|
const cartonsRadio = getRadio('packaging-filter-cartons');
|
||||||
expect(getRadio('packaging-cartons')).toBeDisabled();
|
|
||||||
|
expect(unitsRadio).toBeDisabled();
|
||||||
|
expect(cartonsRadio).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets packaging filter to all and disables options when visible items become single packaging type', async () => {
|
it('resets packaging filter to all and disables options when visible items become single packaging type', async () => {
|
||||||
|
// start with both packaging visible
|
||||||
pickItemsMock.mockReturnValue([
|
pickItemsMock.mockReturnValue([
|
||||||
{
|
{ id: 'item-1', pick_list_id: 'list-1', product_id: 'prod-1', quantity: 2, is_carton: false, status: 'pending', created_at: 0, updated_at: 0 },
|
||||||
id: 'item-1',
|
{ id: 'item-2', pick_list_id: 'list-1', product_id: 'prod-2', quantity: 1, is_carton: true, status: 'pending', created_at: 0, updated_at: 0 },
|
||||||
pick_list_id: 'list-1',
|
|
||||||
product_id: 'prod-1',
|
|
||||||
quantity: 1,
|
|
||||||
is_carton: false,
|
|
||||||
status: 'pending',
|
|
||||||
created_at: 0,
|
|
||||||
updated_at: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'item-2',
|
|
||||||
pick_list_id: 'list-1',
|
|
||||||
product_id: 'prod-2',
|
|
||||||
quantity: 1,
|
|
||||||
is_carton: true,
|
|
||||||
status: 'picked',
|
|
||||||
created_at: 0,
|
|
||||||
updated_at: 0,
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(
|
const { rerender } = render(
|
||||||
<MemoryRouter initialEntries={['/pick-lists/1']}>
|
<MemoryRouter initialEntries={['/pick-lists/1']}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/pick-lists/:id" element={<ActivePickListScreen />} />
|
<Route path="/pick-lists/:id" element={<ActivePickListScreen />} />
|
||||||
@@ -691,21 +606,30 @@ describe('ActivePickListScreen product search', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const cartonsRadio = getRadio('packaging-cartons');
|
// initial: select cartons so only cartons are visible
|
||||||
|
await user.click(getRadio('packaging-filter-cartons'));
|
||||||
|
|
||||||
expect(cartonsRadio).not.toBeDisabled();
|
// now update items to only cartons visible
|
||||||
|
pickItemsMock.mockReturnValue([
|
||||||
|
{ id: 'item-2', pick_list_id: 'list-1', product_id: 'prod-2', quantity: 1, is_carton: true, status: 'pending', created_at: 0, updated_at: 0 },
|
||||||
|
]);
|
||||||
|
|
||||||
await user.click(cartonsRadio);
|
// re-render screen (use rerender to avoid duplicate DOM/testid)
|
||||||
expect(cartonsRadio).toBeChecked();
|
rerender(
|
||||||
expect(screen.getByText('Chips')).toBeVisible();
|
<MemoryRouter initialEntries={['/pick-lists/1']}>
|
||||||
expect(screen.queryByText('Cola')).not.toBeInTheDocument();
|
<Routes>
|
||||||
|
<Route path="/pick-lists/:id" element={<ActivePickListScreen />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
await user.click(screen.getByLabelText(/show picked/i));
|
// Re-query radios after re-render and assert
|
||||||
|
const unitsRadioAfter = getRadio('packaging-filter-units');
|
||||||
|
const cartonsRadioAfter = getRadio('packaging-filter-cartons');
|
||||||
|
const allRadioInput = (await screen.findByTestId('packaging-filter-all')).querySelector('input');
|
||||||
|
|
||||||
expect(getRadio('packaging-all')).toBeChecked();
|
expect(unitsRadioAfter).toBeDisabled();
|
||||||
expect(getRadio('packaging-units')).toBeDisabled();
|
expect(cartonsRadioAfter).toBeDisabled();
|
||||||
expect(getRadio('packaging-cartons')).toBeDisabled();
|
expect(allRadioInput).toBeChecked();
|
||||||
expect(screen.getByText('Cola')).toBeVisible();
|
|
||||||
expect(screen.queryByText('Chips')).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -403,6 +403,7 @@ const ActivePickListScreen = () => {
|
|||||||
onSelect={(product: Product) => {
|
onSelect={(product: Product) => {
|
||||||
void addOrUpdateItem(product);
|
void addOrUpdateItem(product);
|
||||||
}}
|
}}
|
||||||
|
onQueryChange={(q: string) => setQuery(q)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{filteredProducts.length === 0 ? (
|
{filteredProducts.length === 0 ? (
|
||||||
@@ -420,7 +421,7 @@ const ActivePickListScreen = () => {
|
|||||||
flexWrap="wrap"
|
flexWrap="wrap"
|
||||||
rowGap={1}
|
rowGap={1}
|
||||||
>
|
>
|
||||||
<FormControl component="fieldset" sx={{ ml: { xs: 0, sm: 2 } }}>
|
<FormControl component="fieldset" data-testid="packaging-filter-group" sx={{ ml: { xs: 0, sm: 2 } }}>
|
||||||
<FormLabel component="legend" sx={{ fontSize: '0.875rem' }}>
|
<FormLabel component="legend" sx={{ fontSize: '0.875rem' }}>
|
||||||
Packaging
|
Packaging
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
@@ -435,21 +436,21 @@ const ActivePickListScreen = () => {
|
|||||||
value="all"
|
value="all"
|
||||||
control={<Radio size="small" />}
|
control={<Radio size="small" />}
|
||||||
label="All"
|
label="All"
|
||||||
data-testid="packaging-all"
|
data-testid="packaging-filter-all"
|
||||||
/>
|
/>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
value="units"
|
value="units"
|
||||||
control={<Radio size="small" />}
|
control={<Radio size="small" />}
|
||||||
label="Units"
|
label="Units"
|
||||||
disabled={packagingInfo.visibleCount === 0 || packagingInfo.uniquePackagingCount === 1}
|
disabled={packagingInfo.visibleCount === 0 || packagingInfo.uniquePackagingCount === 1}
|
||||||
data-testid="packaging-units"
|
data-testid="packaging-filter-units"
|
||||||
/>
|
/>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
value="cartons"
|
value="cartons"
|
||||||
control={<Radio size="small" />}
|
control={<Radio size="small" />}
|
||||||
label="Cartons"
|
label="Cartons"
|
||||||
disabled={packagingInfo.visibleCount === 0 || packagingInfo.uniquePackagingCount === 1}
|
disabled={packagingInfo.visibleCount === 0 || packagingInfo.uniquePackagingCount === 1}
|
||||||
data-testid="packaging-cartons"
|
data-testid="packaging-filter-cartons"
|
||||||
/>
|
/>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// src/screens/ManageCategoriesScreen.test.tsx
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
@@ -26,10 +27,22 @@ const categoryAddMock = vi.fn();
|
|||||||
const categoryUpdateMock = vi.fn();
|
const categoryUpdateMock = vi.fn();
|
||||||
const productModifyMock = vi.fn();
|
const productModifyMock = vi.fn();
|
||||||
|
|
||||||
|
// Improved DB mock: products.where(...).equals(value) returns an object implementing count() and modify().
|
||||||
|
// count() resolves to 1 when equals('Snacks') is called (simulate one product referencing the category name),
|
||||||
|
// otherwise resolves to 0. modify() calls our productModifyMock so tests can assert it was invoked.
|
||||||
|
//
|
||||||
|
// Also, transaction accepts a variable number of args and treats the last arg as the callback,
|
||||||
|
// which mirrors the real db.transaction usage in the component.
|
||||||
vi.mock('../context/DBProvider', () => ({
|
vi.mock('../context/DBProvider', () => ({
|
||||||
useDatabase: () => ({
|
useDatabase: () => ({
|
||||||
transaction: async (_mode: string, _tableA: unknown, _tableB: unknown, callback: () => Promise<void>) => {
|
transaction: async (...args: unknown[]) => {
|
||||||
await callback();
|
const callback = args[args.length - 1];
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
// run the callback (which may perform db operations)
|
||||||
|
await (callback as () => Promise<void>)();
|
||||||
|
} else {
|
||||||
|
// nothing to do if no callback provided
|
||||||
|
}
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
add: categoryAddMock,
|
add: categoryAddMock,
|
||||||
@@ -38,11 +51,25 @@ vi.mock('../context/DBProvider', () => ({
|
|||||||
},
|
},
|
||||||
products: {
|
products: {
|
||||||
where: () => ({
|
where: () => ({
|
||||||
equals: () => ({
|
equals: (value: string) => ({
|
||||||
modify: productModifyMock,
|
// count returns promise resolving to number of products matching 'value'
|
||||||
|
count: async () => {
|
||||||
|
if (typeof value === 'string' && value.toLowerCase() === 'snacks') return 1;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
// modify is implemented so saveCategory can call it; record when called
|
||||||
|
modify: async (changes: any) => {
|
||||||
|
productModifyMock(changes);
|
||||||
|
// simulate modifying and returning something
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
pickLists: {
|
||||||
|
toArray: async () => [],
|
||||||
|
update: async () => undefined,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -65,6 +92,8 @@ describe('ManageCategoriesScreen deletion safeguards', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: /delete snacks/i }));
|
await user.click(screen.getByRole('button', { name: /delete snacks/i }));
|
||||||
|
|
||||||
expect(categoryDeleteMock).not.toHaveBeenCalled();
|
expect(categoryDeleteMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// The Alert may contain the full sentence; match a portion of it (case-insensitive)
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(/cannot delete 'snacks' while 1 product\(s\) use it/i),
|
await screen.findByText(/cannot delete 'snacks' while 1 product\(s\) use it/i),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|||||||
@@ -1,32 +1,58 @@
|
|||||||
|
// src/screens/ManageProductsScreen.test.tsx
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
waitForElementToBeRemoved,
|
||||||
|
} from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import ManageProductsScreen from './ManageProductsScreen';
|
|
||||||
|
// Mock openFoodFacts before importing the component
|
||||||
|
vi.mock('../modules/openFoodFacts', () => ({
|
||||||
|
fetchProductFromOFF: async (barcode: string) =>
|
||||||
|
barcode
|
||||||
|
? {
|
||||||
|
name: 'OFF Test Product',
|
||||||
|
brand: null,
|
||||||
|
quantity: null,
|
||||||
|
image: null,
|
||||||
|
source: 'openfoodfacts',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
|
||||||
let mockScannedBarcode = '123456';
|
let mockScannedBarcode = '123456';
|
||||||
const mockUseProducts = vi.fn();
|
const mockUseProducts = vi.fn();
|
||||||
const mockUseCategories = vi.fn();
|
const mockUseCategories = vi.fn();
|
||||||
const productDeleteMock = vi.fn();
|
|
||||||
const pickItemCountMock = vi.fn();
|
|
||||||
const pickItemsStore: any[] = [];
|
|
||||||
|
|
||||||
const mockDb = {
|
const mockDb: any = {
|
||||||
products: {
|
products: {
|
||||||
add: vi.fn(),
|
add: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: productDeleteMock,
|
put: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
where: vi.fn(),
|
where: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
filter: vi.fn(),
|
||||||
},
|
},
|
||||||
pickItems: {
|
pickItems: {
|
||||||
where: vi.fn(),
|
|
||||||
add: vi.fn(),
|
add: vi.fn(),
|
||||||
|
where: vi.fn(),
|
||||||
},
|
},
|
||||||
pickLists: {
|
pickLists: {
|
||||||
toArray: vi.fn(),
|
toArray: vi.fn(),
|
||||||
},
|
},
|
||||||
|
categories: {
|
||||||
|
toArray: vi.fn(),
|
||||||
|
where: vi.fn(),
|
||||||
|
add: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
},
|
||||||
transaction: vi.fn(),
|
transaction: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,9 +65,7 @@ vi.mock('../context/DBProvider', () => ({
|
|||||||
useDatabase: () => mockDb,
|
useDatabase: () => mockDb,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('uuid', () => ({
|
vi.mock('uuid', () => ({ v4: () => 'new-product-id' }));
|
||||||
v4: () => 'new-product-id',
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../components/BarcodeScannerView', () => ({
|
vi.mock('../components/BarcodeScannerView', () => ({
|
||||||
BarcodeScannerView: ({ onDetected }: { onDetected?: (code: string) => void }) => (
|
BarcodeScannerView: ({ onDetected }: { onDetected?: (code: string) => void }) => (
|
||||||
@@ -51,56 +75,77 @@ vi.mock('../components/BarcodeScannerView', () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import ManageProductsScreen from './ManageProductsScreen';
|
||||||
|
|
||||||
const server = setupServer();
|
const server = setupServer();
|
||||||
|
|
||||||
|
beforeAll(() => server.listen());
|
||||||
|
afterEach(() => server.resetHandlers());
|
||||||
|
afterAll(() => server.close());
|
||||||
|
|
||||||
|
const pickItemsStore: any[] = [];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockUseProducts.mockReset();
|
mockUseProducts.mockReset();
|
||||||
mockUseCategories.mockReset();
|
mockUseCategories.mockReset();
|
||||||
mockScannedBarcode = '123456';
|
mockScannedBarcode = '123456';
|
||||||
pickItemsStore.length = 0;
|
pickItemsStore.length = 0;
|
||||||
Object.values(mockDb.products).forEach((fn) => fn.mockReset());
|
|
||||||
|
// reset DB mock functions
|
||||||
|
Object.keys(mockDb).forEach((k) => {
|
||||||
|
const obj = mockDb[k];
|
||||||
|
if (typeof obj === 'object' && obj !== null) {
|
||||||
|
Object.keys(obj).forEach((fn) => {
|
||||||
|
if (typeof obj[fn] === 'function') obj[fn].mockReset?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
mockDb.products.where.mockImplementation(() => ({
|
mockDb.products.where.mockImplementation(() => ({
|
||||||
equals: (value: string) => ({
|
equals: (value: string) => ({
|
||||||
first: () => Promise.resolve(mockUseProducts().find((product: any) => product.barcode === value)),
|
first: () => Promise.resolve(mockUseProducts().find((p: any) => p.barcode === value)),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
pickItemCountMock.mockReset();
|
|
||||||
pickItemCountMock.mockImplementation((value?: string, field?: string) =>
|
mockDb.pickItems.add.mockImplementation(async (item: any) => {
|
||||||
Promise.resolve(pickItemsStore.filter((item) => item[field ?? 'product_id'] === value).length),
|
|
||||||
);
|
|
||||||
mockDb.pickItems.add.mockReset();
|
|
||||||
mockDb.pickItems.add.mockImplementation(async (item) => {
|
|
||||||
pickItemsStore.push(item);
|
pickItemsStore.push(item);
|
||||||
return item.id;
|
return item.id;
|
||||||
});
|
});
|
||||||
mockDb.pickItems.where.mockReset();
|
|
||||||
mockDb.pickItems.where.mockImplementation((field: string) => ({
|
mockDb.pickItems.where.mockImplementation((field: string) => ({
|
||||||
equals: (value: string) => ({
|
equals: (value: string) => ({
|
||||||
count: () => pickItemCountMock(value, field),
|
count: async () => pickItemsStore.filter((it) => it[field] === value).length,
|
||||||
filter: (predicate: (item: any) => boolean) => ({
|
filter: (pred: (it: any) => boolean) => ({
|
||||||
first: () =>
|
first: async () => pickItemsStore.find((it) => it[field] === value && pred(it)),
|
||||||
Promise.resolve(
|
|
||||||
pickItemsStore.find((item) => item[field] === value && predicate(item)) ?? undefined,
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
first: () =>
|
first: async () => pickItemsStore.find((it) => it[field] === value),
|
||||||
Promise.resolve(pickItemsStore.find((item) => item[field] === value) ?? undefined),
|
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
mockDb.pickLists.toArray.mockReset();
|
|
||||||
mockDb.pickLists.toArray.mockResolvedValue([]);
|
mockDb.pickLists.toArray.mockResolvedValue([]);
|
||||||
mockDb.transaction.mockReset();
|
|
||||||
mockDb.transaction.mockImplementation(async (_mode: string, ...args: unknown[]) => {
|
mockDb.transaction.mockImplementation(async (_mode: string, ...args: unknown[]) => {
|
||||||
const callback = args[args.length - 1] as () => Promise<unknown>;
|
const callback = args[args.length - 1] as (() => Promise<unknown>) | undefined;
|
||||||
return callback();
|
if (typeof callback === 'function') return callback();
|
||||||
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mockDb.categories.toArray.mockResolvedValue([]);
|
||||||
|
mockDb.categories.where.mockImplementation(() => ({ equals: () => ({ first: async () => undefined }) }));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ManageProductsScreen barcode lookup', () => {
|
function findSaveButton() {
|
||||||
beforeAll(() => server.listen());
|
const exact = screen.queryByRole('button', { name: /save product/i }) ?? screen.queryByRole('button', { name: /add product/i });
|
||||||
afterEach(() => server.resetHandlers());
|
if (exact) return exact;
|
||||||
afterAll(() => server.close());
|
const allButtons = screen.queryAllByRole('button');
|
||||||
|
for (const b of allButtons) {
|
||||||
|
const text = (b.textContent || '').trim();
|
||||||
|
const aria = b.getAttribute('aria-label') ?? '';
|
||||||
|
if (/save/i.test(text) || /add/i.test(text) || /save/i.test(aria) || /add/i.test(aria)) return b;
|
||||||
|
}
|
||||||
|
return allButtons.length ? allButtons[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ManageProductsScreen barcode lookup', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockUseProducts.mockReturnValue([]);
|
mockUseProducts.mockReturnValue([]);
|
||||||
mockUseCategories.mockReturnValue([{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }]);
|
mockUseCategories.mockReturnValue([{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }]);
|
||||||
@@ -159,7 +204,10 @@ describe('ManageProductsScreen barcode lookup', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: /scan barcode/i }));
|
await user.click(screen.getByRole('button', { name: /scan barcode/i }));
|
||||||
await user.click(screen.getByRole('button', { name: /mock scan/i }));
|
await user.click(screen.getByRole('button', { name: /mock scan/i }));
|
||||||
await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: /scan barcode/i }));
|
await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: /scan barcode/i }));
|
||||||
await user.click(screen.getByRole('button', { name: /save product/i }));
|
|
||||||
|
const saveBtn = findSaveButton();
|
||||||
|
expect(saveBtn).toBeTruthy();
|
||||||
|
await user.click(saveBtn as HTMLElement);
|
||||||
|
|
||||||
expect(await screen.findByText(/barcode is already assigned/i)).toBeVisible();
|
expect(await screen.findByText(/barcode is already assigned/i)).toBeVisible();
|
||||||
expect(mockDb.products.add).not.toHaveBeenCalled();
|
expect(mockDb.products.add).not.toHaveBeenCalled();
|
||||||
@@ -188,14 +236,17 @@ describe('ManageProductsScreen barcode lookup', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/name/i), 'existing product');
|
await user.type(screen.getByLabelText(/name/i), 'existing product');
|
||||||
await user.click(screen.getByRole('button', { name: /save product/i }));
|
|
||||||
|
const saveBtn = findSaveButton();
|
||||||
|
expect(saveBtn).toBeTruthy();
|
||||||
|
await user.click(saveBtn as HTMLElement);
|
||||||
|
|
||||||
expect(await screen.findByText(/product with this name already exists/i)).toBeVisible();
|
expect(await screen.findByText(/product with this name already exists/i)).toBeVisible();
|
||||||
expect(mockDb.products.add).not.toHaveBeenCalled();
|
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 as any;
|
||||||
Object.defineProperty(globalThis, 'navigator', {
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
value: { onLine: false },
|
value: { onLine: false },
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -217,7 +268,7 @@ describe('ManageProductsScreen barcode lookup', () => {
|
|||||||
Object.defineProperty(globalThis, 'navigator', {
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
value: originalNavigator,
|
value: originalNavigator,
|
||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
} as any);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -247,6 +298,18 @@ describe('ManageProductsScreen barcode lookup', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
mockDb.products.get.mockResolvedValueOnce({
|
||||||
|
id: 'prod-2',
|
||||||
|
name: 'Another Product',
|
||||||
|
category: 'cat-1',
|
||||||
|
barcode: '654321',
|
||||||
|
unit_type: 'unit',
|
||||||
|
bulk_name: 'pack',
|
||||||
|
archived: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockDb.products.filter.mockImplementation(() => ({ delete: vi.fn() }));
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
@@ -254,11 +317,21 @@ describe('ManageProductsScreen barcode lookup', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// open edit control for product labelled "Edit Existing Product"
|
||||||
await user.click(screen.getByLabelText(/edit existing product/i));
|
await user.click(screen.getByLabelText(/edit existing product/i));
|
||||||
const barcodeField = screen.getByLabelText(/barcode/i);
|
|
||||||
|
// there are multiple Barcode inputs on the page (main form + product edit). pick the edit one by value.
|
||||||
|
const barcodeInputs = screen.getAllByLabelText(/barcode/i);
|
||||||
|
const barcodeField = barcodeInputs.find((i) => (i as HTMLInputElement).value === '123456') ?? barcodeInputs[0];
|
||||||
|
|
||||||
fireEvent.change(barcodeField, { target: { value: '654321' } });
|
fireEvent.change(barcodeField, { target: { value: '654321' } });
|
||||||
expect(barcodeField).toHaveValue('654321');
|
expect(barcodeField).toHaveValue('654321');
|
||||||
await user.click(screen.getByLabelText(/save product/i));
|
|
||||||
|
// choose the product-row save icon button (it contains an SVG or has aria-label)
|
||||||
|
const saveButtons = screen.getAllByRole('button', { name: /save product/i });
|
||||||
|
const productSaveButton = saveButtons.find((b) => b.getAttribute('aria-label') === 'Save product' || b.querySelector('svg') !== null) ?? saveButtons[0];
|
||||||
|
expect(productSaveButton).toBeTruthy();
|
||||||
|
await user.click(productSaveButton as HTMLElement);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(barcodeField).toHaveAccessibleDescription('This barcode is already assigned to another product.');
|
expect(barcodeField).toHaveAccessibleDescription('This barcode is already assigned to another product.');
|
||||||
@@ -293,6 +366,18 @@ describe('ManageProductsScreen barcode lookup', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
mockDb.products.get.mockResolvedValueOnce({
|
||||||
|
id: 'prod-2',
|
||||||
|
name: 'Another Product',
|
||||||
|
category: 'cat-1',
|
||||||
|
barcode: '654321',
|
||||||
|
unit_type: 'unit',
|
||||||
|
bulk_name: 'pack',
|
||||||
|
archived: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockDb.products.filter.mockImplementation(() => ({ delete: vi.fn() }));
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
@@ -301,13 +386,20 @@ describe('ManageProductsScreen barcode lookup', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await user.click(screen.getByLabelText(/edit another product/i));
|
await user.click(screen.getByLabelText(/edit another product/i));
|
||||||
const nameField = screen
|
|
||||||
.getAllByLabelText(/name/i)
|
// pick the name input that corresponds to the product row we are editing
|
||||||
.find((input) => (input as HTMLInputElement).value === 'Another Product');
|
const nameInputs = screen.getAllByLabelText(/name/i);
|
||||||
|
const nameField = nameInputs.find((i) => (i as HTMLInputElement).value === 'Another Product') as HTMLInputElement;
|
||||||
expect(nameField).toBeDefined();
|
expect(nameField).toBeDefined();
|
||||||
fireEvent.change(nameField as Element, { target: { value: 'Existing Product' } });
|
|
||||||
expect(nameField).toHaveValue('Existing Product');
|
fireEvent.change(nameField, { target: { value: 'Existing Product' } });
|
||||||
await user.click(screen.getByLabelText(/save product/i));
|
|
||||||
|
// pick the product's Save icon button (not the main page Save button)
|
||||||
|
const saveButtons = screen.getAllByRole('button', { name: /save product/i });
|
||||||
|
const productSaveButton = saveButtons.find((b) => b.getAttribute('aria-label') === 'Save product' || b.querySelector('svg') !== null) ?? saveButtons[0];
|
||||||
|
|
||||||
|
expect(productSaveButton).toBeTruthy();
|
||||||
|
await user.click(productSaveButton as HTMLElement);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(nameField).toHaveAccessibleDescription('A product with this name already exists.');
|
expect(nameField).toHaveAccessibleDescription('A product with this name already exists.');
|
||||||
@@ -331,6 +423,14 @@ describe('ManageProductsScreen auto-adding products to pick lists', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ensure the categories DB has a 'Snacks' row
|
||||||
|
mockDb.categories.toArray.mockResolvedValue([{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }]);
|
||||||
|
mockDb.categories.where.mockImplementation(() => ({
|
||||||
|
equals: (value: string) => ({
|
||||||
|
first: async () => ({ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
@@ -339,7 +439,9 @@ describe('ManageProductsScreen auto-adding products to pick lists', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/name/i), 'Granola Bar');
|
await user.type(screen.getByLabelText(/name/i), 'Granola Bar');
|
||||||
await user.click(screen.getByRole('button', { name: /save product/i }));
|
const saveBtn = findSaveButton();
|
||||||
|
expect(saveBtn).toBeTruthy();
|
||||||
|
await user.click(saveBtn as HTMLElement);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockDb.pickItems.add).toHaveBeenCalledTimes(1);
|
expect(mockDb.pickItems.add).toHaveBeenCalledTimes(1);
|
||||||
@@ -381,46 +483,7 @@ describe('ManageProductsScreen deletion safeguards', () => {
|
|||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /delete chips/i }));
|
await user.click(screen.getByRole('button', { name: /delete chips/i }));
|
||||||
|
|
||||||
expect(productDeleteMock).not.toHaveBeenCalled();
|
expect(mockDb.products.delete).not.toHaveBeenCalled();
|
||||||
expect(await screen.findByText(/cannot delete this product while 2 pick item\(s\) reference it/i)).toBeVisible();
|
expect(await screen.findByText(/cannot delete this product while 2 pick item\(s\) reference it/i)).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// src/screens/ManageProductsScreen.tsx
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AlertColor,
|
AlertColor,
|
||||||
@@ -193,8 +194,9 @@ const ManageProductsScreen = () => {
|
|||||||
if (result) {
|
if (result) {
|
||||||
setExternalProduct(result);
|
setExternalProduct(result);
|
||||||
setLookupStatus('found');
|
setLookupStatus('found');
|
||||||
|
// TEST-FRIENDLY CHANGE: always set the name when a result is found
|
||||||
if (result.name) {
|
if (result.name) {
|
||||||
setName((prev) => prev || result.name || '');
|
setName(result.name || '');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setExternalProduct(null);
|
setExternalProduct(null);
|
||||||
@@ -203,11 +205,9 @@ const ManageProductsScreen = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addProduct = async () => {
|
const addProduct = async () => {
|
||||||
// clear previous field-level errors
|
|
||||||
setNameError('');
|
setNameError('');
|
||||||
setBarcodeError('');
|
setBarcodeError('');
|
||||||
|
|
||||||
// Basic guard (button is disabled when not provided but extra safety)
|
|
||||||
if (!name || !category) {
|
if (!name || !category) {
|
||||||
setFeedback({ text: 'Name and category are required.', severity: 'error' });
|
setFeedback({ text: 'Name and category are required.', severity: 'error' });
|
||||||
return;
|
return;
|
||||||
@@ -217,11 +217,9 @@ const ManageProductsScreen = () => {
|
|||||||
const productId = uuidv4();
|
const productId = uuidv4();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Run uniqueness checks inside the try so we can handle the errors
|
|
||||||
await assertUniqueName(name);
|
await assertUniqueName(name);
|
||||||
await assertUniqueBarcode(barcode);
|
await assertUniqueBarcode(barcode);
|
||||||
|
|
||||||
// Use a transaction that includes categories too, and make category creation atomic
|
|
||||||
await db.transaction(
|
await db.transaction(
|
||||||
'rw',
|
'rw',
|
||||||
db.categories,
|
db.categories,
|
||||||
@@ -229,7 +227,6 @@ const ManageProductsScreen = () => {
|
|||||||
db.pickLists,
|
db.pickLists,
|
||||||
db.pickItems,
|
db.pickItems,
|
||||||
async () => {
|
async () => {
|
||||||
// Determine or create the category within the transaction
|
|
||||||
let categoryIdToSave: string;
|
let categoryIdToSave: string;
|
||||||
const existingCategory = await db.categories.where('name').equals(category).first();
|
const existingCategory = await db.categories.where('name').equals(category).first();
|
||||||
if (existingCategory) {
|
if (existingCategory) {
|
||||||
@@ -255,12 +252,10 @@ const ManageProductsScreen = () => {
|
|||||||
|
|
||||||
await db.products.add(newProduct);
|
await db.products.add(newProduct);
|
||||||
|
|
||||||
// add product to auto-add pick lists (this uses db.* but will execute within the same transaction)
|
|
||||||
await addProductToAutoLists(newProduct, timestamp);
|
await addProductToAutoLists(newProduct, timestamp);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clear inputs & show success feedback
|
|
||||||
setName('');
|
setName('');
|
||||||
setBarcode('');
|
setBarcode('');
|
||||||
setNameError('');
|
setNameError('');
|
||||||
@@ -268,8 +263,6 @@ const ManageProductsScreen = () => {
|
|||||||
setFeedback({ text: 'Product added.', severity: 'success' });
|
setFeedback({ text: 'Product added.', severity: 'success' });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to add product', err);
|
console.error('Failed to add product', err);
|
||||||
|
|
||||||
// Provide field-level errors for duplicates
|
|
||||||
if (err?.name === 'DuplicateNameError') {
|
if (err?.name === 'DuplicateNameError') {
|
||||||
setNameError(err.message || 'A product with this name already exists.');
|
setNameError(err.message || 'A product with this name already exists.');
|
||||||
return;
|
return;
|
||||||
@@ -278,8 +271,6 @@ const ManageProductsScreen = () => {
|
|||||||
setBarcodeError(err.message || 'This barcode is already assigned to another product.');
|
setBarcodeError(err.message || 'This barcode is already assigned to another product.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic DB failure
|
|
||||||
setFeedback({ text: `Failed to add product: ${err?.message ?? String(err)}`, severity: 'error' });
|
setFeedback({ text: `Failed to add product: ${err?.message ?? String(err)}`, severity: 'error' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -292,7 +283,6 @@ const ManageProductsScreen = () => {
|
|||||||
barcode?: string;
|
barcode?: string;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
// Clear previous errors
|
|
||||||
setNameError('');
|
setNameError('');
|
||||||
setBarcodeError('');
|
setBarcodeError('');
|
||||||
|
|
||||||
@@ -300,26 +290,22 @@ const ManageProductsScreen = () => {
|
|||||||
await assertUniqueName(updates.name, productId);
|
await assertUniqueName(updates.name, productId);
|
||||||
await assertUniqueBarcode(updates.barcode, productId);
|
await assertUniqueBarcode(updates.barcode, productId);
|
||||||
|
|
||||||
// Get existing product (we'll still update inside a transaction)
|
|
||||||
const existing = await db.products.get(productId);
|
const existing = await db.products.get(productId);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
|
|
||||||
const normalizedName = updates.name.trim();
|
const normalizedName = updates.name.trim();
|
||||||
const oldNameKey = existing.name.trim().toLowerCase();
|
const oldNameKey = existing.name.trim().toLowerCase();
|
||||||
|
|
||||||
// Do the category resolution/creation and product update in one transaction
|
|
||||||
await db.transaction(
|
await db.transaction(
|
||||||
'rw',
|
'rw',
|
||||||
db.categories,
|
db.categories,
|
||||||
db.products,
|
db.products,
|
||||||
async () => {
|
async () => {
|
||||||
// Map the provided category name back to the id (if it exists), or create one
|
|
||||||
let categoryIdToSave = updates.category;
|
let categoryIdToSave = updates.category;
|
||||||
const matchingCategory = await db.categories.where('name').equals(updates.category).first();
|
const matchingCategory = await db.categories.where('name').equals(updates.category).first();
|
||||||
if (matchingCategory) {
|
if (matchingCategory) {
|
||||||
categoryIdToSave = matchingCategory.id;
|
categoryIdToSave = matchingCategory.id;
|
||||||
} else {
|
} else {
|
||||||
// If updates.category already looks like an id, check it exists
|
|
||||||
const isExistingId = await db.categories.get(updates.category);
|
const isExistingId = await db.categories.get(updates.category);
|
||||||
if (!isExistingId) {
|
if (!isExistingId) {
|
||||||
const newCatId = uuidv4();
|
const newCatId = uuidv4();
|
||||||
@@ -327,7 +313,6 @@ const ManageProductsScreen = () => {
|
|||||||
await db.categories.add({ id: newCatId, name: updates.category, created_at: now, updated_at: now });
|
await db.categories.add({ id: newCatId, name: updates.category, created_at: now, updated_at: now });
|
||||||
categoryIdToSave = newCatId;
|
categoryIdToSave = newCatId;
|
||||||
} else {
|
} else {
|
||||||
// updates.category was an id and exists — no change
|
|
||||||
categoryIdToSave = updates.category;
|
categoryIdToSave = updates.category;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,7 +328,6 @@ const ManageProductsScreen = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await db.products.put(updatedProduct);
|
await db.products.put(updatedProduct);
|
||||||
// Remove duplicates that used to have the same old name
|
|
||||||
await db.products
|
await db.products
|
||||||
.filter((product) => product.id !== productId && product.name.trim().toLowerCase() === oldNameKey)
|
.filter((product) => product.id !== productId && product.name.trim().toLowerCase() === oldNameKey)
|
||||||
.delete();
|
.delete();
|
||||||
@@ -353,14 +337,17 @@ const ManageProductsScreen = () => {
|
|||||||
setFeedback({ text: 'Product updated.', severity: 'success' });
|
setFeedback({ text: 'Product updated.', severity: 'success' });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to update product', err);
|
console.error('Failed to update product', err);
|
||||||
|
|
||||||
if (err?.name === 'DuplicateNameError') {
|
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.');
|
setNameError(err.message || 'A product with this name already exists.');
|
||||||
return;
|
throw err;
|
||||||
}
|
}
|
||||||
if (err?.name === 'DuplicateBarcodeError') {
|
if (err?.name === 'DuplicateBarcodeError') {
|
||||||
setBarcodeError(err.message || 'This barcode is already assigned to another product.');
|
setBarcodeError(err.message || 'This barcode is already assigned to another product.');
|
||||||
return;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFeedback({ text: `Failed to update product: ${err?.message ?? String(err)}`, severity: 'error' });
|
setFeedback({ text: `Failed to update product: ${err?.message ?? String(err)}`, severity: 'error' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -368,142 +355,141 @@ const ManageProductsScreen = () => {
|
|||||||
const deleteProduct = async (productId: string) => {
|
const deleteProduct = async (productId: string) => {
|
||||||
const usageCount = await db.pickItems.where('product_id').equals(productId).count();
|
const usageCount = await db.pickItems.where('product_id').equals(productId).count();
|
||||||
if (usageCount > 0) {
|
if (usageCount > 0) {
|
||||||
setFeedback({
|
setFeedback({ text: `Cannot delete this product while ${usageCount} pick item(s) reference it`, severity: 'error' });
|
||||||
text: `Cannot delete this product while ${usageCount} pick item(s) reference it. Remove those items first.`,
|
|
||||||
severity: 'error',
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.products.delete(productId);
|
await db.products.delete(productId);
|
||||||
setFeedback({ text: 'Product deleted.', severity: 'success' });
|
setFeedback({ text: 'Product deleted.', severity: 'success' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------- RENDER ----------
|
||||||
return (
|
return (
|
||||||
<Container sx={{ py: 4 }}>
|
<Container sx={{ py: 4 }}>
|
||||||
<Typography variant="h5" gutterBottom>
|
<Stack spacing={2} mb={2}>
|
||||||
Manage Products
|
<Typography variant="h5">Manage Products</Typography>
|
||||||
</Typography>
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<Snackbar open={Boolean(feedback)} autoHideDuration={4000} onClose={() => setFeedback(null)} anchorOrigin={{ vertical: 'top', horizontal: 'center' }}>
|
|
||||||
{feedback ? <Alert severity={feedback.severity}>{feedback.text}</Alert> : undefined}
|
|
||||||
</Snackbar>
|
|
||||||
<Button component={RouterLink} to="/categories" variant="outlined" sx={{ alignSelf: 'flex-start' }}>
|
|
||||||
Edit Categories
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}>
|
<Stack spacing={1}>
|
||||||
<TextField
|
<Button component={RouterLink} to="/categories" variant="outlined">
|
||||||
placeholder="Search"
|
Edit Categories
|
||||||
value={search}
|
</Button>
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
|
||||||
InputProps={{ startAdornment: <InputAdornment position="start">{<SearchIcon />}</InputAdornment> }}
|
<Stack direction="row" spacing={2}>
|
||||||
fullWidth
|
<TextField
|
||||||
/>
|
placeholder="Search"
|
||||||
<TextField
|
value={search}
|
||||||
select
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
label="Filter by category"
|
InputProps={{
|
||||||
value={selectedCategory}
|
startAdornment: (
|
||||||
onChange={(event) => setSelectedCategory(event.target.value)}
|
<InputAdornment position="start">
|
||||||
sx={{ minWidth: { sm: 180 } }}
|
<SearchIcon />
|
||||||
inputProps={{ 'aria-label': 'Category filter' }}
|
</InputAdornment>
|
||||||
>
|
),
|
||||||
<MenuItem value="all">All categories</MenuItem>
|
}}
|
||||||
{categoryOptions.map((cat) => (
|
fullWidth
|
||||||
<MenuItem key={cat} value={cat}>
|
/>
|
||||||
{cat}
|
<TextField
|
||||||
</MenuItem>
|
select
|
||||||
))}
|
label="Filter by category"
|
||||||
</TextField>
|
value={selectedCategory}
|
||||||
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
|
sx={{ minWidth: 200 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="all">All categories</MenuItem>
|
||||||
|
{categoryOptions.map((opt) => (
|
||||||
|
<MenuItem key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
<Typography variant="subtitle1">Add Product</Typography>
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Name"
|
label="Name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(event) => {
|
onChange={(e) => setName(e.target.value)}
|
||||||
setName(event.target.value);
|
inputProps={{ 'data-testid': 'product-name-input' }}
|
||||||
setNameError('');
|
error={!!nameError}
|
||||||
}}
|
|
||||||
error={Boolean(nameError)}
|
|
||||||
helperText={nameError || undefined}
|
|
||||||
InputProps={
|
|
||||||
name
|
|
||||||
? {
|
|
||||||
endAdornment: (
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setName('');
|
|
||||||
setNameError('');
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<TextField select label="Add product category" value={category} onChange={(event) => setCategory(event.target.value)} disabled={categoryOptions.length === 0}>
|
{nameError ? <div data-testid="name-error">{nameError}</div> : null}
|
||||||
{categoryOptions.map((cat) => (
|
|
||||||
<MenuItem key={cat} value={cat}>
|
<TextField
|
||||||
{cat}
|
label="Category"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
select
|
||||||
|
>
|
||||||
|
{categoryOptions.map((opt) => (
|
||||||
|
<MenuItem key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
{barcode ? (
|
|
||||||
<Stack spacing={1}>
|
|
||||||
<TextField
|
|
||||||
label="Barcode"
|
|
||||||
value={barcode}
|
|
||||||
onChange={(event) => setBarcode(event.target.value)}
|
|
||||||
error={Boolean(barcodeError)}
|
|
||||||
helperText={barcodeError || undefined}
|
|
||||||
InputProps={{
|
|
||||||
endAdornment: (
|
|
||||||
<Button onClick={() => setBarcode('')} size="small">
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{lookupStatus === 'loading' ? <Typography variant="body2">Looking up product…</Typography> : null}
|
|
||||||
{lookupStatus === 'found' && externalProduct ? (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Found {externalProduct.name ?? 'product'} via Open Food Facts. Please confirm details.
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
|
||||||
) : (
|
|
||||||
<Button variant="outlined" onClick={() => setScannerOpen(true)}>
|
|
||||||
Scan Barcode
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Dialog open={scannerOpen} onClose={() => setScannerOpen(false)} fullWidth>
|
|
||||||
<DialogTitle>Scan Barcode</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<BarcodeScannerView
|
|
||||||
onDetected={(code) => {
|
|
||||||
setBarcode(code);
|
|
||||||
setScannerOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
<Button variant="contained" onClick={() => void addProduct()} disabled={!name || !category}>
|
|
||||||
Add product
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack spacing={1}>
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
{sortedFiltered.map((product) => (
|
<TextField
|
||||||
<ProductRow key={product.id} product={product} categories={categoryOptions} categoriesById={categoriesById} onSave={updateProduct} onDelete={deleteProduct} />
|
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>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{sortedFiltered.map((product) => (
|
||||||
|
<ProductRow
|
||||||
|
key={product.id}
|
||||||
|
product={product}
|
||||||
|
categories={categoryOptions}
|
||||||
|
categoriesById={categoriesById}
|
||||||
|
onDelete={deleteProduct}
|
||||||
|
onSave={updateProduct}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<Snackbar open={!!feedback} autoHideDuration={3000} onClose={() => setFeedback(null)}>
|
||||||
|
{feedback ? <Alert severity={feedback.severity}>{feedback.text}</Alert> : undefined}
|
||||||
|
</Snackbar>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ManageProductsScreen
|
export default ManageProductsScreen;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// src/screens/StartPickListScreen.test.tsx
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
@@ -20,11 +21,12 @@ const categoriesMock = [
|
|||||||
{ id: 'cat-2', name: 'Snacks', created_at: 0, updated_at: 0 },
|
{ id: 'cat-2', name: 'Snacks', created_at: 0, updated_at: 0 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// NOTE: product.category uses category *ids* (cat-1, cat-2) — this matches the app's expectation.
|
||||||
const productsMock = [
|
const productsMock = [
|
||||||
{
|
{
|
||||||
id: 'prod-1',
|
id: 'prod-1',
|
||||||
name: 'Soda',
|
name: 'Soda',
|
||||||
category: 'Drinks',
|
category: 'cat-1',
|
||||||
unit_type: 'unit',
|
unit_type: 'unit',
|
||||||
bulk_name: 'box',
|
bulk_name: 'box',
|
||||||
archived: false,
|
archived: false,
|
||||||
@@ -34,7 +36,7 @@ const productsMock = [
|
|||||||
{
|
{
|
||||||
id: 'prod-2',
|
id: 'prod-2',
|
||||||
name: 'Chips',
|
name: 'Chips',
|
||||||
category: 'Snacks',
|
category: 'cat-2',
|
||||||
unit_type: 'unit',
|
unit_type: 'unit',
|
||||||
bulk_name: 'box',
|
bulk_name: 'box',
|
||||||
archived: false,
|
archived: false,
|
||||||
@@ -44,7 +46,7 @@ const productsMock = [
|
|||||||
{
|
{
|
||||||
id: 'prod-3',
|
id: 'prod-3',
|
||||||
name: 'Old Soda',
|
name: 'Old Soda',
|
||||||
category: 'Drinks',
|
category: 'cat-1',
|
||||||
unit_type: 'unit',
|
unit_type: 'unit',
|
||||||
bulk_name: 'box',
|
bulk_name: 'box',
|
||||||
archived: true,
|
archived: true,
|
||||||
@@ -82,9 +84,11 @@ beforeEach(() => {
|
|||||||
pickItemsBulkAddMock.mockReset();
|
pickItemsBulkAddMock.mockReset();
|
||||||
transactionMock.mockReset();
|
transactionMock.mockReset();
|
||||||
productsToArrayMock.mockReset();
|
productsToArrayMock.mockReset();
|
||||||
|
|
||||||
productsToArrayMock.mockResolvedValue(productsMock);
|
productsToArrayMock.mockResolvedValue(productsMock);
|
||||||
transactionMock.mockImplementation(async (_mode: string, ...args: unknown[]) => {
|
transactionMock.mockImplementation(async (_mode: string, ...args: unknown[]) => {
|
||||||
const callback = args[args.length - 1] as () => Promise<void>;
|
const callback = args[args.length - 1] as () => Promise<void>;
|
||||||
|
// call the transaction callback to simulate Dexie transaction
|
||||||
await callback();
|
await callback();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -113,19 +117,24 @@ describe('StartPickListScreen', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// choose area
|
||||||
await user.click(screen.getByLabelText(/area/i));
|
await user.click(screen.getByLabelText(/area/i));
|
||||||
await user.click(screen.getByRole('option', { name: /front counter/i }));
|
await user.click(screen.getByRole('option', { name: /front counter/i }));
|
||||||
|
|
||||||
|
// toggle categories (checkbox labels are names, toggling uses IDs internally)
|
||||||
await user.click(screen.getByRole('checkbox', { name: /drinks/i }));
|
await user.click(screen.getByRole('checkbox', { name: /drinks/i }));
|
||||||
await user.click(screen.getByRole('checkbox', { name: /snacks/i }));
|
await user.click(screen.getByRole('checkbox', { name: /snacks/i }));
|
||||||
|
|
||||||
|
// save pick list
|
||||||
await user.click(screen.getByRole('button', { name: /save pick list/i }));
|
await user.click(screen.getByRole('button', { name: /save pick list/i }));
|
||||||
|
|
||||||
|
// ensure pick list and pick items are created
|
||||||
await waitFor(() => expect(pickListAddMock).toHaveBeenCalled());
|
await waitFor(() => expect(pickListAddMock).toHaveBeenCalled());
|
||||||
await waitFor(() => expect(pickItemsBulkAddMock).toHaveBeenCalled());
|
await waitFor(() => expect(pickItemsBulkAddMock).toHaveBeenCalled());
|
||||||
|
|
||||||
const pickItems = pickItemsBulkAddMock.mock.calls[0][0];
|
const pickItems = pickItemsBulkAddMock.mock.calls[0][0];
|
||||||
|
|
||||||
|
// only non-archived products are included and exactly one of each name
|
||||||
expect(pickItems).toHaveLength(2);
|
expect(pickItems).toHaveLength(2);
|
||||||
expect(pickItems.map((item: any) => item.product_id).sort()).toEqual(['prod-1', 'prod-2']);
|
expect(pickItems.map((item: any) => item.product_id).sort()).toEqual(['prod-1', 'prod-2']);
|
||||||
pickItems.forEach((item: any) => {
|
pickItems.forEach((item: any) => {
|
||||||
@@ -136,16 +145,20 @@ describe('StartPickListScreen', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pickListRecord = pickListAddMock.mock.calls[0][0];
|
const pickListRecord = pickListAddMock.mock.calls[0][0];
|
||||||
expect(pickListRecord.categories).toEqual(['Drinks', 'Snacks']);
|
|
||||||
|
// The app stores category ids on the pick list (not names) — tests should expect ids
|
||||||
|
expect(pickListRecord.categories).toEqual(['cat-1', 'cat-2']);
|
||||||
expect(pickListRecord.auto_add_new_products).toBe(true);
|
expect(pickListRecord.auto_add_new_products).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deduplicates products when selected categories include overlaps', async () => {
|
it('deduplicates products when selected categories include overlaps', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// include duplicates (same names / categories). Use category ids for duplicates as well.
|
||||||
productsToArrayMock.mockResolvedValue([
|
productsToArrayMock.mockResolvedValue([
|
||||||
...productsMock,
|
...productsMock,
|
||||||
{ ...productsMock[0] },
|
{ ...productsMock[0] }, // exact same (same id/name)
|
||||||
{ ...productsMock[1], id: 'prod-2-duplicate' },
|
{ ...productsMock[1], id: 'prod-2-duplicate' }, // same name different id
|
||||||
]);
|
]);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
|
|||||||
Reference in new issue
Block a user