Merge pull request #65 from beatz174-bit/codex/add-checkbox-for-new-products-on-pick-list
Add auto-add option for pick lists and sync new products
This commit is contained in:
@@ -202,6 +202,38 @@ export class StockFillDB extends Dexie {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
this.version(7)
|
||||
.stores({
|
||||
products: 'id, name, category, &barcode, archived, created_at, updated_at',
|
||||
areas: 'id, name, created_at, updated_at',
|
||||
pickLists:
|
||||
'id, area_id, created_at, completed_at, auto_add_new_products, categories',
|
||||
pickItems:
|
||||
'id, pick_list_id, product_id, status, is_carton, quantity, created_at, updated_at',
|
||||
categories: 'id, name, created_at, updated_at',
|
||||
})
|
||||
.upgrade(async (tx) => {
|
||||
const pickLists = await tx.table('pickLists').toArray();
|
||||
|
||||
await Promise.all(
|
||||
pickLists.map((pickList) => {
|
||||
const updates: Partial<PickList> = {};
|
||||
|
||||
if (!Array.isArray((pickList as PickList).categories)) {
|
||||
updates.categories = [];
|
||||
}
|
||||
|
||||
if (typeof (pickList as PickList).auto_add_new_products !== 'boolean') {
|
||||
updates.auto_add_new_products = false;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) return undefined;
|
||||
|
||||
return tx.table('pickLists').update(pickList.id, updates);
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,4 +4,6 @@ export interface PickList {
|
||||
created_at: number;
|
||||
completed_at?: number;
|
||||
notes?: string;
|
||||
categories: string[];
|
||||
auto_add_new_products: boolean;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,13 @@ vi.mock('../hooks/dataHooks', () => ({
|
||||
updated_at: 0,
|
||||
},
|
||||
],
|
||||
usePickList: () => ({ id: 'list-1', area_id: 'area-1', created_at: 0 }),
|
||||
usePickList: () => ({
|
||||
id: 'list-1',
|
||||
area_id: 'area-1',
|
||||
created_at: 0,
|
||||
categories: [],
|
||||
auto_add_new_products: false,
|
||||
}),
|
||||
useAreas: () => [{ id: 'area-1', name: 'Front Counter', created_at: 0, updated_at: 0 }],
|
||||
}));
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ const mockUseProducts = vi.fn();
|
||||
const mockUseCategories = vi.fn();
|
||||
const productDeleteMock = vi.fn();
|
||||
const pickItemCountMock = vi.fn();
|
||||
const pickItemsStore: any[] = [];
|
||||
|
||||
const mockDb = {
|
||||
products: {
|
||||
@@ -21,7 +22,12 @@ const mockDb = {
|
||||
},
|
||||
pickItems: {
|
||||
where: vi.fn(),
|
||||
add: vi.fn(),
|
||||
},
|
||||
pickLists: {
|
||||
toArray: vi.fn(),
|
||||
},
|
||||
transaction: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('../hooks/dataHooks', () => ({
|
||||
@@ -33,6 +39,10 @@ vi.mock('../context/DBProvider', () => ({
|
||||
useDatabase: () => mockDb,
|
||||
}));
|
||||
|
||||
vi.mock('uuid', () => ({
|
||||
v4: () => 'new-product-id',
|
||||
}));
|
||||
|
||||
vi.mock('../components/BarcodeScannerView', () => ({
|
||||
BarcodeScannerView: ({ onDetected }: { onDetected?: (code: string) => void }) => (
|
||||
<button type="button" onClick={() => onDetected?.(mockScannedBarcode)}>
|
||||
@@ -47,6 +57,7 @@ beforeEach(() => {
|
||||
mockUseProducts.mockReset();
|
||||
mockUseCategories.mockReset();
|
||||
mockScannedBarcode = '123456';
|
||||
pickItemsStore.length = 0;
|
||||
Object.values(mockDb.products).forEach((fn) => fn.mockReset());
|
||||
mockDb.products.where.mockImplementation(() => ({
|
||||
equals: (value: string) => ({
|
||||
@@ -54,10 +65,35 @@ beforeEach(() => {
|
||||
}),
|
||||
}));
|
||||
pickItemCountMock.mockReset();
|
||||
pickItemCountMock.mockImplementation((value?: string, field?: string) =>
|
||||
Promise.resolve(pickItemsStore.filter((item) => item[field ?? 'product_id'] === value).length),
|
||||
);
|
||||
mockDb.pickItems.add.mockReset();
|
||||
mockDb.pickItems.add.mockImplementation(async (item) => {
|
||||
pickItemsStore.push(item);
|
||||
return item.id;
|
||||
});
|
||||
mockDb.pickItems.where.mockReset();
|
||||
mockDb.pickItems.where.mockImplementation(() => ({
|
||||
equals: () => ({ count: pickItemCountMock }),
|
||||
mockDb.pickItems.where.mockImplementation((field: string) => ({
|
||||
equals: (value: string) => ({
|
||||
count: () => pickItemCountMock(value, field),
|
||||
filter: (predicate: (item: any) => boolean) => ({
|
||||
first: () =>
|
||||
Promise.resolve(
|
||||
pickItemsStore.find((item) => item[field] === value && predicate(item)) ?? undefined,
|
||||
),
|
||||
}),
|
||||
first: () =>
|
||||
Promise.resolve(pickItemsStore.find((item) => item[field] === value) ?? undefined),
|
||||
}),
|
||||
}));
|
||||
mockDb.pickLists.toArray.mockReset();
|
||||
mockDb.pickLists.toArray.mockResolvedValue([]);
|
||||
mockDb.transaction.mockReset();
|
||||
mockDb.transaction.mockImplementation(async (_mode: string, ...args: unknown[]) => {
|
||||
const callback = args[args.length - 1] as () => Promise<unknown>;
|
||||
return callback();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ManageProductsScreen barcode lookup', () => {
|
||||
@@ -176,6 +212,41 @@ describe('ManageProductsScreen barcode lookup', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ManageProductsScreen auto-adding products to pick lists', () => {
|
||||
it('adds a new product to matching pick lists when auto-add is enabled', async () => {
|
||||
mockUseProducts.mockReturnValue([]);
|
||||
mockUseCategories.mockReturnValue([{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }]);
|
||||
mockDb.pickLists.toArray.mockResolvedValue([
|
||||
{
|
||||
id: 'list-1',
|
||||
area_id: 'area-1',
|
||||
created_at: 0,
|
||||
categories: ['Snacks'],
|
||||
auto_add_new_products: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ManageProductsScreen />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByLabelText(/name/i), 'Granola Bar');
|
||||
await user.click(screen.getByRole('button', { name: /save product/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDb.pickItems.add).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const addedItem = mockDb.pickItems.add.mock.calls[0][0];
|
||||
expect(addedItem.pick_list_id).toBe('list-1');
|
||||
expect(addedItem.product_id).toBe('new-product-id');
|
||||
expect(addedItem.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ManageProductsScreen deletion safeguards', () => {
|
||||
it('blocks deletion when pick items reference the product', async () => {
|
||||
mockUseProducts.mockReturnValue([
|
||||
@@ -191,7 +262,10 @@ describe('ManageProductsScreen deletion safeguards', () => {
|
||||
},
|
||||
]);
|
||||
mockUseCategories.mockReturnValue([{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }]);
|
||||
pickItemCountMock.mockResolvedValueOnce(2);
|
||||
pickItemsStore.push(
|
||||
{ id: 'item-1', product_id: 'prod-chips', pick_list_id: 'list-1' },
|
||||
{ id: 'item-2', product_id: 'prod-chips', pick_list_id: 'list-2' },
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
||||
@@ -19,9 +19,9 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { ProductRow } from '../components/ProductRow';
|
||||
import { useCategories, useProducts } from '../hooks/dataHooks';
|
||||
import { useDatabase } from '../context/DBProvider';
|
||||
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE } from '../models/Product';
|
||||
import { BarcodeScannerView } from '../components/BarcodeScannerView';
|
||||
import { ExternalProductInfo, fetchProductFromOFF } from '../modules/openFoodFacts';
|
||||
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE, Product } from '../models/Product';
|
||||
|
||||
export const ManageProductsScreen = () => {
|
||||
const db = useDatabase();
|
||||
@@ -94,6 +94,44 @@ export const ManageProductsScreen = () => {
|
||||
[db.products, findBarcodeConflict],
|
||||
);
|
||||
|
||||
const addProductToAutoLists = useCallback(
|
||||
async (product: Product, timestamp: number) => {
|
||||
const pickLists = await db.pickLists.toArray();
|
||||
const eligibleLists = pickLists.filter(
|
||||
(pickList) =>
|
||||
pickList.auto_add_new_products && Array.isArray(pickList.categories)
|
||||
? pickList.categories.includes(product.category)
|
||||
: false,
|
||||
);
|
||||
|
||||
if (eligibleLists.length === 0) return;
|
||||
|
||||
await Promise.all(
|
||||
eligibleLists.map(async (pickList) => {
|
||||
const existing = await db.pickItems
|
||||
.where('pick_list_id')
|
||||
.equals(pickList.id)
|
||||
.filter((item) => item.product_id === product.id)
|
||||
.first();
|
||||
|
||||
if (existing) return undefined;
|
||||
|
||||
return db.pickItems.add({
|
||||
id: uuidv4(),
|
||||
pick_list_id: pickList.id,
|
||||
product_id: product.id,
|
||||
quantity: 1,
|
||||
is_carton: false,
|
||||
status: 'pending',
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
[db.pickItems, db.pickLists],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (categoryOptions.length === 0) return;
|
||||
if (!categoryOptions.includes(category)) {
|
||||
@@ -137,17 +175,23 @@ export const ManageProductsScreen = () => {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await db.products.add({
|
||||
id: uuidv4(),
|
||||
const timestamp = Date.now();
|
||||
const productId = uuidv4();
|
||||
const newProduct: Product = {
|
||||
id: productId,
|
||||
name,
|
||||
category,
|
||||
unit_type: DEFAULT_UNIT_TYPE,
|
||||
bulk_name: DEFAULT_BULK_NAME,
|
||||
barcode: barcode || undefined,
|
||||
archived: false,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
};
|
||||
|
||||
await db.transaction('rw', db.products, db.pickLists, db.pickItems, async () => {
|
||||
await db.products.add(newProduct);
|
||||
await addProductToAutoLists(newProduct, timestamp);
|
||||
});
|
||||
setName('');
|
||||
setBarcode('');
|
||||
|
||||
@@ -10,9 +10,9 @@ const areasMock = [
|
||||
];
|
||||
|
||||
const pickListsMock = [
|
||||
{ id: 'list-2', area_id: 'area-2', created_at: 3 },
|
||||
{ id: 'list-3', area_id: 'area-3', created_at: 4 },
|
||||
{ id: 'list-1', area_id: 'area-1', created_at: 5 },
|
||||
{ id: 'list-2', area_id: 'area-2', created_at: 3, categories: [], auto_add_new_products: false },
|
||||
{ id: 'list-3', area_id: 'area-3', created_at: 4, categories: [], auto_add_new_products: false },
|
||||
{ id: 'list-1', area_id: 'area-1', created_at: 5, categories: [], auto_add_new_products: false },
|
||||
];
|
||||
|
||||
vi.mock('../hooks/dataHooks', () => ({
|
||||
|
||||
@@ -100,6 +100,8 @@ describe('StartPickListScreen', () => {
|
||||
categoriesMock.forEach((category) => {
|
||||
expect(screen.getByRole('checkbox', { name: category.name })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: /add new products/i })).toBeChecked();
|
||||
});
|
||||
|
||||
it('prefills a new pick list with products from selected categories', async () => {
|
||||
@@ -132,6 +134,10 @@ describe('StartPickListScreen', () => {
|
||||
expect(item.quantity).toBe(1);
|
||||
expect(item.status).toBe('pending');
|
||||
});
|
||||
|
||||
const pickListRecord = pickListAddMock.mock.calls[0][0];
|
||||
expect(pickListRecord.categories).toEqual(['Drinks', 'Snacks']);
|
||||
expect(pickListRecord.auto_add_new_products).toBe(true);
|
||||
});
|
||||
|
||||
it('deduplicates products when selected categories include overlaps', async () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ export const StartPickListScreen = () => {
|
||||
const [areaId, setAreaId] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [autoAddNewProducts, setAutoAddNewProducts] = useState(true);
|
||||
|
||||
const sortedCategories = useMemo(
|
||||
() =>
|
||||
@@ -55,6 +56,8 @@ export const StartPickListScreen = () => {
|
||||
area_id: areaId,
|
||||
created_at: timestamp,
|
||||
notes: notes.trim() || undefined,
|
||||
categories: selectedCategoryNames,
|
||||
auto_add_new_products: autoAddNewProducts,
|
||||
});
|
||||
|
||||
if (selectedCategoryNames.length === 0) {
|
||||
@@ -143,6 +146,15 @@ export const StartPickListScreen = () => {
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={autoAddNewProducts}
|
||||
onChange={(event) => setAutoAddNewProducts(event.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Add new products"
|
||||
/>
|
||||
</Stack>
|
||||
<Button variant="contained" disabled={!areaId} onClick={start}>
|
||||
Save Pick List
|
||||
|
||||
Reference in New Issue
Block a user