Prevent deletion of in-use records
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ManageAreasScreen } from './ManageAreasScreen';
|
||||||
|
|
||||||
|
const areasMock = [{ id: 'area-1', name: 'Front Counter', created_at: 0, updated_at: 0 }];
|
||||||
|
|
||||||
|
vi.mock('../hooks/dataHooks', () => ({
|
||||||
|
useAreas: () => areasMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const areaDeleteMock = vi.fn();
|
||||||
|
const pickListCountMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../context/DBProvider', () => ({
|
||||||
|
useDatabase: () => ({
|
||||||
|
areas: {
|
||||||
|
add: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: areaDeleteMock,
|
||||||
|
},
|
||||||
|
pickLists: {
|
||||||
|
where: () => ({
|
||||||
|
equals: () => ({
|
||||||
|
count: pickListCountMock,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ManageAreasScreen deletion safeguards', () => {
|
||||||
|
it('prevents deleting an area that is used by pick lists', async () => {
|
||||||
|
pickListCountMock.mockResolvedValueOnce(1);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ManageAreasScreen />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /delete front counter/i }));
|
||||||
|
|
||||||
|
expect(areaDeleteMock).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/cannot delete this area while 1 pick list\(s\) use it/i),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertColor,
|
||||||
Button,
|
Button,
|
||||||
Container,
|
Container,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -24,6 +26,7 @@ export const ManageAreasScreen = () => {
|
|||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [editingAreaId, setEditingAreaId] = useState<string | null>(null);
|
const [editingAreaId, setEditingAreaId] = useState<string | null>(null);
|
||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
|
const [feedback, setFeedback] = useState<{ text: string; severity: AlertColor } | null>(null);
|
||||||
|
|
||||||
const addArea = async () => {
|
const addArea = async () => {
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
@@ -34,6 +37,7 @@ export const ManageAreasScreen = () => {
|
|||||||
const startEditing = (areaId: string, currentName: string) => {
|
const startEditing = (areaId: string, currentName: string) => {
|
||||||
setEditingAreaId(areaId);
|
setEditingAreaId(areaId);
|
||||||
setEditName(currentName);
|
setEditName(currentName);
|
||||||
|
setFeedback(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveArea = async () => {
|
const saveArea = async () => {
|
||||||
@@ -41,6 +45,7 @@ export const ManageAreasScreen = () => {
|
|||||||
await db.areas.update(editingAreaId, { name: editName, updated_at: Date.now() });
|
await db.areas.update(editingAreaId, { name: editName, updated_at: Date.now() });
|
||||||
setEditingAreaId(null);
|
setEditingAreaId(null);
|
||||||
setEditName('');
|
setEditName('');
|
||||||
|
setFeedback({ text: 'Area updated.', severity: 'success' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEditing = () => {
|
const cancelEditing = () => {
|
||||||
@@ -49,10 +54,19 @@ export const ManageAreasScreen = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteArea = async (areaId: string) => {
|
const deleteArea = async (areaId: string) => {
|
||||||
|
const usageCount = await db.pickLists.where('area_id').equals(areaId).count();
|
||||||
|
if (usageCount > 0) {
|
||||||
|
setFeedback({
|
||||||
|
text: `Cannot delete this area while ${usageCount} pick list(s) use it. Remove those lists first.`,
|
||||||
|
severity: 'error',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
await db.areas.delete(areaId);
|
await db.areas.delete(areaId);
|
||||||
if (editingAreaId === areaId) {
|
if (editingAreaId === areaId) {
|
||||||
cancelEditing();
|
cancelEditing();
|
||||||
}
|
}
|
||||||
|
setFeedback({ text: 'Area deleted.', severity: 'success' });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,6 +75,7 @@ export const ManageAreasScreen = () => {
|
|||||||
Manage Areas
|
Manage Areas
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
|
{feedback ? <Alert severity={feedback.severity}>{feedback.text}</Alert> : null}
|
||||||
<Stack direction="row" spacing={1}>
|
<Stack direction="row" spacing={1}>
|
||||||
<TextField fullWidth label="Area name" value={name} onChange={(event) => setName(event.target.value)} />
|
<TextField fullWidth label="Area name" value={name} onChange={(event) => setName(event.target.value)} />
|
||||||
<Button variant="contained" onClick={addArea} disabled={!name}>
|
<Button variant="contained" onClick={addArea} disabled={!name}>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ManageCategoriesScreen } from './ManageCategoriesScreen';
|
||||||
|
|
||||||
|
const categoriesMock = [{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }];
|
||||||
|
const productsMock = [
|
||||||
|
{
|
||||||
|
id: 'prod-1',
|
||||||
|
name: 'Chips',
|
||||||
|
category: 'Snacks',
|
||||||
|
archived: false,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mock('../hooks/dataHooks', () => ({
|
||||||
|
useCategories: () => categoriesMock,
|
||||||
|
useProducts: () => productsMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const categoryDeleteMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../context/DBProvider', () => ({
|
||||||
|
useDatabase: () => ({
|
||||||
|
categories: {
|
||||||
|
add: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: categoryDeleteMock,
|
||||||
|
},
|
||||||
|
products: {
|
||||||
|
where: () => ({
|
||||||
|
equals: () => ({
|
||||||
|
modify: vi.fn(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ManageCategoriesScreen deletion safeguards', () => {
|
||||||
|
it('shows an error when trying to delete an in-use category', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ManageCategoriesScreen />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /delete snacks/i }));
|
||||||
|
|
||||||
|
expect(categoryDeleteMock).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/cannot delete 'snacks' while 1 product\(s\) use it/i),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,17 +6,30 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
import { ManageProductsScreen } from './ManageProductsScreen';
|
import { ManageProductsScreen } from './ManageProductsScreen';
|
||||||
|
|
||||||
|
const productsMock = [{ id: 'prod-1', name: 'Chips', category: 'Snacks', archived: false, created_at: 0, updated_at: 0 }];
|
||||||
|
const categoriesMock = [{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }];
|
||||||
|
|
||||||
vi.mock('../hooks/dataHooks', () => ({
|
vi.mock('../hooks/dataHooks', () => ({
|
||||||
useProducts: () => [],
|
useProducts: () => productsMock,
|
||||||
useCategories: () => [{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }],
|
useCategories: () => categoriesMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const productDeleteMock = vi.fn();
|
||||||
|
const pickItemCountMock = vi.fn();
|
||||||
|
|
||||||
vi.mock('../context/DBProvider', () => ({
|
vi.mock('../context/DBProvider', () => ({
|
||||||
useDatabase: () => ({
|
useDatabase: () => ({
|
||||||
products: {
|
products: {
|
||||||
add: vi.fn(),
|
add: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: productDeleteMock,
|
||||||
|
},
|
||||||
|
pickItems: {
|
||||||
|
where: () => ({
|
||||||
|
equals: () => ({
|
||||||
|
count: pickItemCountMock,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -64,3 +77,21 @@ describe('ManageProductsScreen barcode lookup', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ManageProductsScreen deletion safeguards', () => {
|
||||||
|
it('blocks deletion when pick items reference the product', async () => {
|
||||||
|
pickItemCountMock.mockResolvedValueOnce(2);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ManageProductsScreen />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /delete chips/i }));
|
||||||
|
|
||||||
|
expect(productDeleteMock).not.toHaveBeenCalled();
|
||||||
|
expect(await screen.findByText(/cannot delete this product while 2 pick item\(s\) reference it/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertColor,
|
||||||
Button,
|
Button,
|
||||||
Container,
|
Container,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
InputAdornment,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
InputAdornment,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import { Link as RouterLink, useLocation } from 'react-router-dom';
|
import { Link as RouterLink, useLocation } from 'react-router-dom';
|
||||||
@@ -35,6 +37,7 @@ export const ManageProductsScreen = () => {
|
|||||||
'idle',
|
'idle',
|
||||||
);
|
);
|
||||||
const [externalProduct, setExternalProduct] = useState<ExternalProductInfo | null>(null);
|
const [externalProduct, setExternalProduct] = useState<ExternalProductInfo | null>(null);
|
||||||
|
const [feedback, setFeedback] = useState<{ text: string; severity: AlertColor } | null>(null);
|
||||||
|
|
||||||
const lookupBarcode = useCallback(async (code: string) => {
|
const lookupBarcode = useCallback(async (code: string) => {
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
@@ -111,6 +114,7 @@ export const ManageProductsScreen = () => {
|
|||||||
});
|
});
|
||||||
setName('');
|
setName('');
|
||||||
setBarcode('');
|
setBarcode('');
|
||||||
|
setFeedback({ text: 'Product added.', severity: 'success' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateProduct = async (
|
const updateProduct = async (
|
||||||
@@ -127,10 +131,20 @@ export const ManageProductsScreen = () => {
|
|||||||
bulk_name: DEFAULT_BULK_NAME,
|
bulk_name: DEFAULT_BULK_NAME,
|
||||||
updated_at: Date.now(),
|
updated_at: Date.now(),
|
||||||
});
|
});
|
||||||
|
setFeedback({ text: 'Product updated.', severity: 'success' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteProduct = async (productId: string) => {
|
const deleteProduct = async (productId: string) => {
|
||||||
|
const usageCount = await db.pickItems.where('product_id').equals(productId).count();
|
||||||
|
if (usageCount > 0) {
|
||||||
|
setFeedback({
|
||||||
|
text: `Cannot delete this product while ${usageCount} pick item(s) reference it. Remove those items first.`,
|
||||||
|
severity: 'error',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
await db.products.delete(productId);
|
await db.products.delete(productId);
|
||||||
|
setFeedback({ text: 'Product deleted.', severity: 'success' });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -139,6 +153,7 @@ export const ManageProductsScreen = () => {
|
|||||||
Manage Products
|
Manage Products
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
|
{feedback ? <Alert severity={feedback.severity}>{feedback.text}</Alert> : null}
|
||||||
<Button component={RouterLink} to="/categories" variant="outlined" sx={{ alignSelf: 'flex-start' }}>
|
<Button component={RouterLink} to="/categories" variant="outlined" sx={{ alignSelf: 'flex-start' }}>
|
||||||
Edit Categories
|
Edit Categories
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user