Add category selection when starting pick lists
This commit is contained in:
+12
-13
@@ -1,37 +1,36 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
const areaName = 'Drinks';
|
const areaName = 'Drinks';
|
||||||
const firstProduct = 'Mount Franklin 600ml';
|
const chocolateProduct = 'Mars Bar';
|
||||||
const secondProduct = 'Mars Bar';
|
const chipsProduct = 'Smiths Salt n Vinegar 90g';
|
||||||
|
const additionalProduct = 'Pump 750';
|
||||||
|
|
||||||
test.describe('Active pick list', () => {
|
test.describe('Active pick list', () => {
|
||||||
test('allows creating a pick list and adding products without crashing', async ({ page }) => {
|
test('creates a pick list with category-prefilled items and adds more products', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'StockFill' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'StockFill' })).toBeVisible();
|
||||||
await page.getByRole('link', { name: 'Create Pick List' }).click();
|
await page.getByRole('link', { name: 'Create Pick List' }).click();
|
||||||
await page.getByLabel('Area').click();
|
await page.getByLabel('Area').click();
|
||||||
await page.getByRole('option', { name: areaName }).first().click();
|
await page.getByRole('option', { name: areaName }).first().click();
|
||||||
|
await page.getByLabel('Category (optional)').click();
|
||||||
|
await page.getByRole('option', { name: 'Chocolates' }).click();
|
||||||
|
await page.getByRole('checkbox', { name: 'Chips' }).click();
|
||||||
await page.getByRole('button', { name: 'Save Pick List' }).click();
|
await page.getByRole('button', { name: 'Save Pick List' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: `${areaName} List` })).toBeVisible();
|
await expect(page.getByRole('heading', { name: `${areaName} List` })).toBeVisible();
|
||||||
|
await expect(page.getByText(chocolateProduct).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(chipsProduct).first()).toBeVisible();
|
||||||
|
|
||||||
const searchInput = page.getByPlaceholder('Search products');
|
const searchInput = page.getByPlaceholder('Search products');
|
||||||
await searchInput.click();
|
await searchInput.click();
|
||||||
await searchInput.fill(firstProduct);
|
await searchInput.fill(additionalProduct);
|
||||||
await page
|
await page
|
||||||
.getByRole('option', { name: new RegExp(`${firstProduct} \\(${areaName}\\)`, 'i') })
|
.getByRole('option', { name: new RegExp(`${additionalProduct} \\(${areaName}\\)`, 'i') })
|
||||||
.first()
|
.first()
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
await expect(page.getByText(firstProduct).first()).toBeVisible();
|
await expect(page.getByText(additionalProduct).first()).toBeVisible();
|
||||||
await expect(page.getByText(/Qty: 1 unit/i)).toBeVisible();
|
|
||||||
|
|
||||||
await searchInput.click();
|
|
||||||
await searchInput.fill(secondProduct);
|
|
||||||
await page.getByRole('option', { name: new RegExp(secondProduct, 'i') }).first().click();
|
|
||||||
|
|
||||||
await expect(page.getByText(secondProduct).first()).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: 'Save and Return' })).toBeEnabled();
|
await expect(page.getByRole('button', { name: 'Save and Return' })).toBeEnabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { StartPickListScreen } from './StartPickListScreen';
|
||||||
|
|
||||||
|
const navigateMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('react-router-dom', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('react-router-dom')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigate: () => navigateMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const areasMock = [{ id: 'area-1', name: 'Front Counter', created_at: 0, updated_at: 0 }];
|
||||||
|
const categoriesMock = [
|
||||||
|
{ id: 'cat-1', name: 'Drinks', created_at: 0, updated_at: 0 },
|
||||||
|
{ id: 'cat-2', name: 'Snacks', created_at: 0, updated_at: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const productsMock = [
|
||||||
|
{
|
||||||
|
id: 'prod-1',
|
||||||
|
name: 'Soda',
|
||||||
|
category: 'Drinks',
|
||||||
|
unit_type: 'unit',
|
||||||
|
bulk_name: 'box',
|
||||||
|
archived: false,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prod-2',
|
||||||
|
name: 'Chips',
|
||||||
|
category: 'Snacks',
|
||||||
|
unit_type: 'unit',
|
||||||
|
bulk_name: 'box',
|
||||||
|
archived: false,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prod-3',
|
||||||
|
name: 'Old Soda',
|
||||||
|
category: 'Drinks',
|
||||||
|
unit_type: 'unit',
|
||||||
|
bulk_name: 'box',
|
||||||
|
archived: true,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const pickListAddMock = vi.fn();
|
||||||
|
const pickItemsBulkAddMock = vi.fn();
|
||||||
|
const transactionMock = vi.fn();
|
||||||
|
const productsToArrayMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../hooks/dataHooks', () => ({
|
||||||
|
useAreas: () => areasMock,
|
||||||
|
useCategories: () => categoriesMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../context/DBProvider', () => ({
|
||||||
|
useDatabase: () => ({
|
||||||
|
pickLists: { add: pickListAddMock },
|
||||||
|
pickItems: { bulkAdd: pickItemsBulkAddMock },
|
||||||
|
products: { toArray: productsToArrayMock },
|
||||||
|
transaction: transactionMock,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('uuid', () => ({
|
||||||
|
v4: () => 'generated-id',
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
navigateMock.mockReset();
|
||||||
|
pickListAddMock.mockReset();
|
||||||
|
pickItemsBulkAddMock.mockReset();
|
||||||
|
transactionMock.mockReset();
|
||||||
|
productsToArrayMock.mockReset();
|
||||||
|
productsToArrayMock.mockResolvedValue(productsMock);
|
||||||
|
transactionMock.mockImplementation(async (_mode: string, ...args: unknown[]) => {
|
||||||
|
const callback = args[args.length - 1] as () => Promise<void>;
|
||||||
|
await callback();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StartPickListScreen', () => {
|
||||||
|
it('shows category selection controls', () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<StartPickListScreen />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/category \(optional\)/i)).toBeVisible();
|
||||||
|
categoriesMock.forEach((category) => {
|
||||||
|
expect(screen.getByRole('checkbox', { name: category.name })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefills a new pick list with products from selected categories', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<StartPickListScreen />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText(/area/i));
|
||||||
|
await user.click(screen.getByRole('option', { name: /front counter/i }));
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText(/category \(optional\)/i));
|
||||||
|
await user.click(screen.getByRole('option', { name: /drinks/i }));
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: /snacks/i }));
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /save pick list/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(pickListAddMock).toHaveBeenCalled());
|
||||||
|
await waitFor(() => expect(pickItemsBulkAddMock).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const pickItems = pickItemsBulkAddMock.mock.calls[0][0];
|
||||||
|
|
||||||
|
expect(pickItems).toHaveLength(2);
|
||||||
|
expect(pickItems.map((item: any) => item.product_id).sort()).toEqual(['prod-1', 'prod-2']);
|
||||||
|
pickItems.forEach((item: any) => {
|
||||||
|
expect(item.pick_list_id).toBe('generated-id');
|
||||||
|
expect(item.is_carton).toBe(false);
|
||||||
|
expect(item.quantity).toBe(1);
|
||||||
|
expect(item.status).toBe('pending');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,26 +1,99 @@
|
|||||||
import { Button, Container, MenuItem, Stack, TextField, Typography } from '@mui/material';
|
import {
|
||||||
import { useState } from 'react';
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Container,
|
||||||
|
FormControlLabel,
|
||||||
|
FormGroup,
|
||||||
|
MenuItem,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { useAreas } from '../hooks/dataHooks';
|
import { useAreas, useCategories } from '../hooks/dataHooks';
|
||||||
import { useDatabase } from '../context/DBProvider';
|
import { useDatabase } from '../context/DBProvider';
|
||||||
|
|
||||||
export const StartPickListScreen = () => {
|
export const StartPickListScreen = () => {
|
||||||
const areas = useAreas();
|
const areas = useAreas();
|
||||||
|
const categories = useCategories();
|
||||||
const db = useDatabase();
|
const db = useDatabase();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [areaId, setAreaId] = useState('');
|
const [areaId, setAreaId] = useState('');
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
|
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||||
|
const [quickCategoryId, setQuickCategoryId] = useState('');
|
||||||
|
|
||||||
|
const sortedCategories = useMemo(
|
||||||
|
() =>
|
||||||
|
[...categories].sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }),
|
||||||
|
),
|
||||||
|
[categories],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleCategory = (categoryId: string) => {
|
||||||
|
setSelectedCategories((current) =>
|
||||||
|
current.includes(categoryId)
|
||||||
|
? current.filter((id) => id !== categoryId)
|
||||||
|
: [...current, categoryId],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectCategory = (categoryId: string) => {
|
||||||
|
setQuickCategoryId(categoryId);
|
||||||
|
if (!categoryId) return;
|
||||||
|
|
||||||
|
setSelectedCategories((current) =>
|
||||||
|
current.includes(categoryId) ? current : [...current, categoryId],
|
||||||
|
);
|
||||||
|
setQuickCategoryId('');
|
||||||
|
};
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
if (!areaId) return;
|
if (!areaId) return;
|
||||||
const pickListId = uuidv4();
|
const pickListId = uuidv4();
|
||||||
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
const selectedCategoryNames = categories
|
||||||
|
.filter((category) => selectedCategories.includes(category.id))
|
||||||
|
.map((category) => category.name);
|
||||||
|
|
||||||
|
await db.transaction('rw', db.pickLists, db.pickItems, db.products, async () => {
|
||||||
await db.pickLists.add({
|
await db.pickLists.add({
|
||||||
id: pickListId,
|
id: pickListId,
|
||||||
area_id: areaId,
|
area_id: areaId,
|
||||||
created_at: Date.now(),
|
created_at: timestamp,
|
||||||
notes: notes.trim() || undefined,
|
notes: notes.trim() || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (selectedCategoryNames.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const products = await db.products.toArray();
|
||||||
|
const productsInCategories = products.filter(
|
||||||
|
(product) => selectedCategoryNames.includes(product.category) && !product.archived,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (productsInCategories.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.pickItems.bulkAdd(
|
||||||
|
productsInCategories.map((product) => ({
|
||||||
|
id: uuidv4(),
|
||||||
|
pick_list_id: pickListId,
|
||||||
|
product_id: product.id,
|
||||||
|
quantity: 1,
|
||||||
|
is_carton: false,
|
||||||
|
status: 'pending',
|
||||||
|
created_at: timestamp,
|
||||||
|
updated_at: timestamp,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
});
|
||||||
navigate(`/pick-lists/${pickListId}`);
|
navigate(`/pick-lists/${pickListId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,6 +119,37 @@ export const StartPickListScreen = () => {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
fullWidth
|
||||||
|
label="Category (optional)"
|
||||||
|
value={quickCategoryId}
|
||||||
|
onChange={(event) => handleSelectCategory(event.target.value as string)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">None</MenuItem>
|
||||||
|
{sortedCategories.map((category) => (
|
||||||
|
<MenuItem key={category.id} value={category.id}>
|
||||||
|
{category.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography variant="subtitle2">Add categories to prefill products</Typography>
|
||||||
|
<FormGroup>
|
||||||
|
{sortedCategories.map((category) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={category.id}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedCategories.includes(category.id)}
|
||||||
|
onChange={() => handleToggleCategory(category.id)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={category.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</FormGroup>
|
||||||
|
</Stack>
|
||||||
<TextField
|
<TextField
|
||||||
label="Notes (optional)"
|
label="Notes (optional)"
|
||||||
value={notes}
|
value={notes}
|
||||||
|
|||||||
Reference in New Issue
Block a user