Validate product name uniqueness
This commit is contained in:
@@ -50,16 +50,16 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [formState, setFormState] = useState<ProductFormState>(() => getInitialFormState(product));
|
const [formState, setFormState] = useState<ProductFormState>(() => getInitialFormState(product));
|
||||||
const [isScannerOpen, setIsScannerOpen] = useState(false);
|
const [isScannerOpen, setIsScannerOpen] = useState(false);
|
||||||
const [saveError, setSaveError] = useState('');
|
const [fieldErrors, setFieldErrors] = useState<{ name?: string; barcode?: string }>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFormState(getInitialFormState(product));
|
setFormState(getInitialFormState(product));
|
||||||
setSaveError('');
|
setFieldErrors({});
|
||||||
}, [product]);
|
}, [product]);
|
||||||
|
|
||||||
const handleChange = (field: keyof ProductFormState) => (event: ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (field: keyof ProductFormState) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
setFormState((prev) => ({ ...prev, [field]: event.target.value }));
|
setFormState((prev) => ({ ...prev, [field]: event.target.value }));
|
||||||
setSaveError('');
|
setFieldErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -71,10 +71,14 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
|
|||||||
barcode: formState.barcode || undefined,
|
barcode: formState.barcode || undefined,
|
||||||
});
|
});
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setSaveError('');
|
setFieldErrors({});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'DuplicateNameError') {
|
||||||
|
setFieldErrors({ name: 'A product with this name already exists.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (error instanceof Error && error.name === 'DuplicateBarcodeError') {
|
if (error instanceof Error && error.name === 'DuplicateBarcodeError') {
|
||||||
setSaveError('This barcode is already assigned to another product.');
|
setFieldErrors({ barcode: 'This barcode is already assigned to another product.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -84,7 +88,7 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
|
|||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setFormState(getInitialFormState(product));
|
setFormState(getInitialFormState(product));
|
||||||
setSaveError('');
|
setFieldErrors({});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -92,7 +96,14 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
<TextField label="Name" value={formState.name} onChange={handleChange('name')} size="small" />
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
value={formState.name}
|
||||||
|
onChange={handleChange('name')}
|
||||||
|
size="small"
|
||||||
|
error={Boolean(fieldErrors.name)}
|
||||||
|
helperText={fieldErrors.name || undefined}
|
||||||
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
select
|
select
|
||||||
label="Category"
|
label="Category"
|
||||||
@@ -112,15 +123,15 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
|
|||||||
value={formState.barcode}
|
value={formState.barcode}
|
||||||
onChange={handleChange('barcode')}
|
onChange={handleChange('barcode')}
|
||||||
size="small"
|
size="small"
|
||||||
error={Boolean(saveError)}
|
error={Boolean(fieldErrors.barcode)}
|
||||||
helperText={saveError || undefined}
|
helperText={fieldErrors.barcode || undefined}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFormState((prev) => ({ ...prev, barcode: '' }));
|
setFormState((prev) => ({ ...prev, barcode: '' }));
|
||||||
setSaveError('');
|
setFieldErrors((prev) => ({ ...prev, barcode: undefined }));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
|
|||||||
@@ -165,6 +165,35 @@ describe('ManageProductsScreen barcode lookup', () => {
|
|||||||
expect(mockDb.products.add).not.toHaveBeenCalled();
|
expect(mockDb.products.add).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prevents adding a product with a duplicate name (case-insensitive)', async () => {
|
||||||
|
mockUseProducts.mockReturnValue([
|
||||||
|
{
|
||||||
|
id: 'prod-1',
|
||||||
|
name: 'Existing Product',
|
||||||
|
category: 'Snacks',
|
||||||
|
barcode: undefined,
|
||||||
|
unit_type: 'unit',
|
||||||
|
bulk_name: 'pack',
|
||||||
|
archived: false,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ManageProductsScreen />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/name/i), 'existing product');
|
||||||
|
await user.click(screen.getByRole('button', { name: /save product/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByText(/product with this name already exists/i)).toBeVisible();
|
||||||
|
expect(mockDb.products.add).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('informs the user when barcode lookup happens offline', async () => {
|
it('informs the user when barcode lookup happens offline', async () => {
|
||||||
const originalNavigator = navigator;
|
const originalNavigator = navigator;
|
||||||
Object.defineProperty(globalThis, 'navigator', {
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
@@ -237,6 +266,55 @@ describe('ManageProductsScreen barcode lookup', () => {
|
|||||||
});
|
});
|
||||||
expect(mockDb.products.update).not.toHaveBeenCalled();
|
expect(mockDb.products.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prevents updating a product to use an existing name', async () => {
|
||||||
|
mockUseProducts.mockReturnValue([
|
||||||
|
{
|
||||||
|
id: 'prod-1',
|
||||||
|
name: 'Existing Product',
|
||||||
|
category: 'Snacks',
|
||||||
|
barcode: '123456',
|
||||||
|
unit_type: 'unit',
|
||||||
|
bulk_name: 'pack',
|
||||||
|
archived: false,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prod-2',
|
||||||
|
name: 'Another Product',
|
||||||
|
category: 'Snacks',
|
||||||
|
barcode: '654321',
|
||||||
|
unit_type: 'unit',
|
||||||
|
bulk_name: 'pack',
|
||||||
|
archived: false,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ManageProductsScreen />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText(/edit another product/i));
|
||||||
|
const nameField = screen
|
||||||
|
.getAllByLabelText(/name/i)
|
||||||
|
.find((input) => (input as HTMLInputElement).value === 'Another Product');
|
||||||
|
expect(nameField).toBeDefined();
|
||||||
|
fireEvent.change(nameField as Element, { target: { value: 'Existing Product' } });
|
||||||
|
expect(nameField).toHaveValue('Existing Product');
|
||||||
|
await user.click(screen.getByLabelText(/save product/i));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(nameField).toHaveAccessibleDescription('A product with this name already exists.');
|
||||||
|
expect(nameField).toHaveAttribute('aria-invalid', 'true');
|
||||||
|
});
|
||||||
|
expect(mockDb.products.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ManageProductsScreen auto-adding products to pick lists', () => {
|
describe('ManageProductsScreen auto-adding products to pick lists', () => {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const ManageProductsScreen = () => {
|
|||||||
const [category, setCategory] = useState('');
|
const [category, setCategory] = useState('');
|
||||||
const [barcode, setBarcode] = useState('');
|
const [barcode, setBarcode] = useState('');
|
||||||
const [barcodeError, setBarcodeError] = useState('');
|
const [barcodeError, setBarcodeError] = useState('');
|
||||||
|
const [nameError, setNameError] = useState('');
|
||||||
const [scannerOpen, setScannerOpen] = useState(false);
|
const [scannerOpen, setScannerOpen] = useState(false);
|
||||||
const [lookupStatus, setLookupStatus] = useState<'idle' | 'loading' | 'found' | 'notfound' | 'offline'>(
|
const [lookupStatus, setLookupStatus] = useState<'idle' | 'loading' | 'found' | 'notfound' | 'offline'>(
|
||||||
'idle',
|
'idle',
|
||||||
@@ -78,6 +79,19 @@ export const ManageProductsScreen = () => {
|
|||||||
[products],
|
[products],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const findNameConflict = useCallback(
|
||||||
|
(value?: string, productId?: string) => {
|
||||||
|
if (!value) return undefined;
|
||||||
|
|
||||||
|
const normalizedValue = value.trim().toLowerCase();
|
||||||
|
|
||||||
|
return products.find(
|
||||||
|
(product) => product.id !== productId && product.name.trim().toLowerCase() === normalizedValue,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[products],
|
||||||
|
);
|
||||||
|
|
||||||
const assertUniqueBarcode = useCallback(
|
const assertUniqueBarcode = useCallback(
|
||||||
async (value?: string, productId?: string) => {
|
async (value?: string, productId?: string) => {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
@@ -94,6 +108,22 @@ export const ManageProductsScreen = () => {
|
|||||||
[db.products, findBarcodeConflict],
|
[db.products, findBarcodeConflict],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const assertUniqueName = useCallback(
|
||||||
|
async (value: string, productId?: string) => {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (!normalized) return;
|
||||||
|
|
||||||
|
const conflict = findNameConflict(value, productId);
|
||||||
|
|
||||||
|
if (conflict) {
|
||||||
|
const error = new Error('A product with this name already exists.');
|
||||||
|
error.name = 'DuplicateNameError';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[findNameConflict],
|
||||||
|
);
|
||||||
|
|
||||||
const addProductToAutoLists = useCallback(
|
const addProductToAutoLists = useCallback(
|
||||||
async (product: Product, timestamp: number) => {
|
async (product: Product, timestamp: number) => {
|
||||||
const pickLists = await db.pickLists.toArray();
|
const pickLists = await db.pickLists.toArray();
|
||||||
@@ -167,8 +197,13 @@ export const ManageProductsScreen = () => {
|
|||||||
if (!name || !category) return;
|
if (!name || !category) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await assertUniqueName(name);
|
||||||
await assertUniqueBarcode(barcode || undefined);
|
await assertUniqueBarcode(barcode || undefined);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'DuplicateNameError') {
|
||||||
|
setNameError(error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (error instanceof Error && error.name === 'DuplicateBarcodeError') {
|
if (error instanceof Error && error.name === 'DuplicateBarcodeError') {
|
||||||
setBarcodeError(error.message);
|
setBarcodeError(error.message);
|
||||||
return;
|
return;
|
||||||
@@ -195,6 +230,7 @@ export const ManageProductsScreen = () => {
|
|||||||
});
|
});
|
||||||
setName('');
|
setName('');
|
||||||
setBarcode('');
|
setBarcode('');
|
||||||
|
setNameError('');
|
||||||
setFeedback({ text: 'Product added.', severity: 'success' });
|
setFeedback({ text: 'Product added.', severity: 'success' });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -206,6 +242,7 @@ export const ManageProductsScreen = () => {
|
|||||||
barcode?: string;
|
barcode?: string;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
|
await assertUniqueName(updates.name, productId);
|
||||||
await assertUniqueBarcode(updates.barcode, productId);
|
await assertUniqueBarcode(updates.barcode, productId);
|
||||||
|
|
||||||
await db.products.update(productId, {
|
await db.products.update(productId, {
|
||||||
@@ -251,12 +288,23 @@ export const ManageProductsScreen = () => {
|
|||||||
<TextField
|
<TextField
|
||||||
label="Name"
|
label="Name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(event) => setName(event.target.value)}
|
onChange={(event) => {
|
||||||
|
setName(event.target.value);
|
||||||
|
setNameError('');
|
||||||
|
}}
|
||||||
|
error={Boolean(nameError)}
|
||||||
|
helperText={nameError || undefined}
|
||||||
InputProps={
|
InputProps={
|
||||||
name
|
name
|
||||||
? {
|
? {
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<Button onClick={() => setName('')} size="small">
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setName('');
|
||||||
|
setNameError('');
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user