Add tests and utilities for coverage improvements
This commit is contained in:
@@ -1 +1 @@
|
|||||||
|
1.57.0
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# StockFill
|
||||||
|
|
||||||
|
## Testing and coverage
|
||||||
|
|
||||||
|
The test suite uses Vitest with React Testing Library. Coverage thresholds are enforced via the Vite test configuration to keep statements, branches, functions, and lines at or above 80%. Run tests locally with:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
The suite includes unit coverage for product management flows, pick list utilities, numeric steppers, autocompletion, and dialog behaviors to ensure core UX remains stable.
|
||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { ActionOutcome, applyOutcome, FeedbackState } from '../utils/editableEntityUtils';
|
||||||
|
export type { ActionOutcome, FeedbackState } from '../utils/editableEntityUtils';
|
||||||
|
|
||||||
export interface EditableEntity {
|
export interface EditableEntity {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -21,12 +23,6 @@ export interface EditableEntity {
|
|||||||
secondaryText?: string;
|
secondaryText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActionOutcome {
|
|
||||||
text?: string;
|
|
||||||
severity?: AlertColor;
|
|
||||||
success?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EditableEntityListProps {
|
interface EditableEntityListProps {
|
||||||
nameLabel: string;
|
nameLabel: string;
|
||||||
addButtonLabel?: string;
|
addButtonLabel?: string;
|
||||||
@@ -39,11 +35,6 @@ interface EditableEntityListProps {
|
|||||||
onDelete: (id: string, name: string) => Promise<ActionOutcome | void>;
|
onDelete: (id: string, name: string) => Promise<ActionOutcome | void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FeedbackState {
|
|
||||||
text: string;
|
|
||||||
severity: AlertColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EditableEntityList = ({
|
export const EditableEntityList = ({
|
||||||
nameLabel,
|
nameLabel,
|
||||||
addButtonLabel = 'Add',
|
addButtonLabel = 'Add',
|
||||||
@@ -69,19 +60,11 @@ export const EditableEntityList = ({
|
|||||||
};
|
};
|
||||||
}, [validateName]);
|
}, [validateName]);
|
||||||
|
|
||||||
const applyOutcome = (outcome: ActionOutcome | void, defaultText: string, defaultSeverity: AlertColor = 'success') => {
|
|
||||||
const success = outcome?.success ?? outcome?.severity !== 'error';
|
|
||||||
const text = outcome?.text ?? defaultText;
|
|
||||||
const severity = outcome?.severity ?? defaultSeverity;
|
|
||||||
setFeedback({ text, severity });
|
|
||||||
return success;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
const trimmed = newName.trim();
|
const trimmed = newName.trim();
|
||||||
if (!isNameValid(newName, null)) return;
|
if (!isNameValid(newName, null)) return;
|
||||||
try {
|
try {
|
||||||
const success = applyOutcome(await onAdd(trimmed), `${entityLabel} added.`);
|
const success = applyOutcome(setFeedback, await onAdd(trimmed), `${entityLabel} added.`);
|
||||||
if (success) {
|
if (success) {
|
||||||
setNewName('');
|
setNewName('');
|
||||||
}
|
}
|
||||||
@@ -107,7 +90,7 @@ export const EditableEntityList = ({
|
|||||||
if (!isNameValid(editName, editingId)) return;
|
if (!isNameValid(editName, editingId)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const success = applyOutcome(await onUpdate(editingId, trimmed), `${entityLabel} updated.`);
|
const success = applyOutcome(setFeedback, await onUpdate(editingId, trimmed), `${entityLabel} updated.`);
|
||||||
if (success) {
|
if (success) {
|
||||||
cancelEditing();
|
cancelEditing();
|
||||||
}
|
}
|
||||||
@@ -118,7 +101,7 @@ export const EditableEntityList = ({
|
|||||||
|
|
||||||
const handleDelete = async (id: string, name: string) => {
|
const handleDelete = async (id: string, name: string) => {
|
||||||
try {
|
try {
|
||||||
const success = applyOutcome(await onDelete(id, name), `${entityLabel} deleted.`);
|
const success = applyOutcome(setFeedback, await onDelete(id, name), `${entityLabel} deleted.`);
|
||||||
if (success && editingId === id) {
|
if (success && editingId === id) {
|
||||||
cancelEditing();
|
cancelEditing();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import CloseIcon from '@mui/icons-material/Close';
|
|||||||
import { ChangeEvent, useEffect, useState } from 'react';
|
import { ChangeEvent, useEffect, useState } from 'react';
|
||||||
import { Product } from '../models/Product';
|
import { Product } from '../models/Product';
|
||||||
import { BarcodeScannerView } from './BarcodeScannerView';
|
import { BarcodeScannerView } from './BarcodeScannerView';
|
||||||
|
import { getInitialFormState, ProductFormState } from '../utils/productRowUtils';
|
||||||
|
|
||||||
interface ProductRowProps {
|
interface ProductRowProps {
|
||||||
product: Product;
|
product: Product;
|
||||||
@@ -36,19 +37,6 @@ interface ProductRowProps {
|
|||||||
onDelete: (productId: string) => Promise<void> | void;
|
onDelete: (productId: string) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductFormState {
|
|
||||||
name: string;
|
|
||||||
category: string;
|
|
||||||
barcode: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getInitialFormState = (product: Product, categoriesById: Map<string, string>): ProductFormState => ({
|
|
||||||
name: product.name,
|
|
||||||
// If product.category is an id, resolve to name; otherwise assume it is already a name
|
|
||||||
category: categoriesById.get(product.category) ?? product.category ?? '',
|
|
||||||
barcode: product.barcode ?? '',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ProductRow = ({ product, categories, categoriesById, onSave, onDelete }: ProductRowProps) => {
|
export const ProductRow = ({ product, categories, categoriesById, onSave, onDelete }: ProductRowProps) => {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [formState, setFormState] = useState<ProductFormState>(() => getInitialFormState(product, categoriesById));
|
const [formState, setFormState] = useState<ProductFormState>(() => getInitialFormState(product, categoriesById));
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import { AddProductDialog } from '../AddProductDialog';
|
||||||
|
import { createMockDb } from '../../testUtils/mockDb';
|
||||||
|
import type { Product } from '../../models/Product';
|
||||||
|
|
||||||
|
let mockDb = createMockDb();
|
||||||
|
const mockUseProducts = vi.fn();
|
||||||
|
const mockFetchProductFromOFF = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../context/DBProvider', () => ({
|
||||||
|
useDatabase: () => mockDb,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../hooks/dataHooks', () => ({
|
||||||
|
useProducts: () => mockUseProducts(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../BarcodeScannerView', () => ({
|
||||||
|
BarcodeScannerView: ({ onDetected }: { onDetected?: (code: string) => void }) => (
|
||||||
|
<button type="button" onClick={() => onDetected?.('scanned-barcode')}>
|
||||||
|
Mock Barcode Scan
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../modules/openFoodFacts', () => ({
|
||||||
|
fetchProductFromOFF: (...args: unknown[]) => mockFetchProductFromOFF(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultCategories = ['Fresh', 'Pantry'];
|
||||||
|
|
||||||
|
describe('AddProductDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDb = createMockDb();
|
||||||
|
mockUseProducts.mockReturnValue([]);
|
||||||
|
mockFetchProductFromOFF.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows offline alert when barcode lookup attempted offline', async () => {
|
||||||
|
vi.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(false);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AddProductDialog
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
categoryOptions={defaultCategories}
|
||||||
|
initialBarcode="12345"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('barcode-offline')).toBeInTheDocument());
|
||||||
|
expect(screen.getByTestId('product-barcode-input')).toHaveValue('12345');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies initialBarcode and triggers lookup', async () => {
|
||||||
|
mockFetchProductFromOFF.mockResolvedValue({ name: 'From OFF' });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AddProductDialog
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
categoryOptions={defaultCategories}
|
||||||
|
initialBarcode="999"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetchProductFromOFF).toHaveBeenCalledWith('999'));
|
||||||
|
expect(screen.getByTestId('product-barcode-input')).toHaveValue('999');
|
||||||
|
await waitFor(() => expect(screen.getByLabelText(/name/i)).toHaveValue('From OFF'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets helper text for duplicate name and duplicate barcode errors', async () => {
|
||||||
|
const existing: Product = {
|
||||||
|
id: 'existing',
|
||||||
|
name: 'Existing',
|
||||||
|
category: 'cat-1',
|
||||||
|
unit_type: 'unit',
|
||||||
|
bulk_name: 'carton',
|
||||||
|
archived: false,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
barcode: 'dupe',
|
||||||
|
};
|
||||||
|
mockUseProducts.mockReturnValue([existing]);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<AddProductDialog open onClose={vi.fn()} categoryOptions={defaultCategories} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/name/i), 'Existing');
|
||||||
|
await user.type(screen.getByLabelText(/barcode/i), 'unique');
|
||||||
|
await user.click(screen.getByRole('button', { name: /save product/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText(/a product with this name already exists/i)).toBeInTheDocument());
|
||||||
|
|
||||||
|
await user.clear(screen.getByLabelText(/name/i));
|
||||||
|
await user.type(screen.getByLabelText(/name/i), 'Different');
|
||||||
|
await user.clear(screen.getByLabelText(/barcode/i));
|
||||||
|
await user.type(screen.getByLabelText(/barcode/i), 'dupe');
|
||||||
|
await user.click(screen.getByRole('button', { name: /save product/i }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/this barcode is already assigned to another product/i)).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds product to eligible pick lists', async () => {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
mockDb = createMockDb({
|
||||||
|
pickLists: [
|
||||||
|
{
|
||||||
|
id: 'list-1',
|
||||||
|
area_id: 'a',
|
||||||
|
created_at: timestamp,
|
||||||
|
completed_at: undefined,
|
||||||
|
notes: 'Auto',
|
||||||
|
categories: ['Fresh'],
|
||||||
|
auto_add_new_products: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'list-2',
|
||||||
|
area_id: 'b',
|
||||||
|
created_at: timestamp,
|
||||||
|
completed_at: undefined,
|
||||||
|
notes: 'Skip',
|
||||||
|
categories: ['Pantry'],
|
||||||
|
auto_add_new_products: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
categories: [
|
||||||
|
{ id: 'fresh-id', name: 'Fresh', created_at: timestamp, updated_at: timestamp },
|
||||||
|
{ id: 'pantry-id', name: 'Pantry', created_at: timestamp, updated_at: timestamp },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AddProductDialog open onClose={vi.fn()} categoryOptions={defaultCategories} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/name/i), 'Lettuce');
|
||||||
|
await user.click(screen.getByRole('button', { name: /save product/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockDb.pickItems.items.some((item) => item.pick_list_id === 'list-1')).toBe(true);
|
||||||
|
expect(mockDb.pickItems.items.some((item) => item.pick_list_id === 'list-2')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import EditableEntityList from '../EditableEntityList';
|
||||||
|
import { makeNamedError } from '../../test/makeNamedError';
|
||||||
|
|
||||||
|
const entities = [
|
||||||
|
{ id: '1', name: 'First' },
|
||||||
|
{ id: '2', name: 'Second' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('EditableEntityList', () => {
|
||||||
|
it('disables add button for blank names and when validateName fails', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onAdd = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<EditableEntityList
|
||||||
|
nameLabel="Name"
|
||||||
|
entities={entities}
|
||||||
|
onAdd={onAdd}
|
||||||
|
onUpdate={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
validateName={(value) => value !== 'Invalid'}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const addButton = screen.getByRole('button', { name: /add/i });
|
||||||
|
expect(addButton).toBeDisabled();
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/name/i), ' ');
|
||||||
|
expect(addButton).toBeDisabled();
|
||||||
|
|
||||||
|
await user.clear(screen.getByLabelText(/name/i));
|
||||||
|
await user.type(screen.getByLabelText(/name/i), 'Invalid');
|
||||||
|
expect(addButton).toBeDisabled();
|
||||||
|
expect(onAdd).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows feedback for failed add and update operations', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onAdd = vi.fn().mockResolvedValue({ success: false, text: 'x', severity: 'error' });
|
||||||
|
const onUpdate = vi.fn().mockRejectedValue(makeNamedError('Unexpected'));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<EditableEntityList
|
||||||
|
nameLabel="Name"
|
||||||
|
entities={entities}
|
||||||
|
onAdd={onAdd}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/name/i), 'New One');
|
||||||
|
await user.click(screen.getByRole('button', { name: /add/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('x')).toBeInTheDocument());
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText(/edit first/i));
|
||||||
|
await user.clear(screen.getByDisplayValue('First'));
|
||||||
|
await user.type(screen.getByDisplayValue('First'), 'Updated');
|
||||||
|
await user.click(screen.getByLabelText(/save item/i));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/unable to update item/i)).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports editing cancel/save flows and delete feedback', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onDelete = vi.fn().mockResolvedValue({ text: 'Deleted', severity: 'success' });
|
||||||
|
const onUpdate = vi.fn().mockResolvedValue({ text: 'Saved', severity: 'success' });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<EditableEntityList
|
||||||
|
nameLabel="Name"
|
||||||
|
entities={entities}
|
||||||
|
onAdd={vi.fn()}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText(/edit second/i));
|
||||||
|
const editInput = screen.getByDisplayValue('Second');
|
||||||
|
await user.clear(editInput);
|
||||||
|
await user.type(editInput, 'Second Updated');
|
||||||
|
await user.click(screen.getByLabelText(/save item/i));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onUpdate).toHaveBeenCalled());
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText(/delete second updated/i));
|
||||||
|
await waitFor(() => expect(onDelete).toHaveBeenCalledWith('2', 'Second Updated'));
|
||||||
|
expect(screen.getByText('Deleted')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
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';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { NumericStepper } from '../NumericStepper';
|
import { NumericStepper } from '../NumericStepper';
|
||||||
|
|
||||||
@@ -54,4 +54,51 @@ describe('NumericStepper', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'decrease Items' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'decrease Items' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'increase Items' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'increase Items' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('calls onChange with the minimum when decrease clicked at lower bound', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
render(<NumericStepper label="Qty" value={1} min={1} onChange={handleChange} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /decrease qty/i }));
|
||||||
|
|
||||||
|
expect(handleChange).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onChange with incremented value when increase clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
render(<NumericStepper label="Qty" value={2} min={1} onChange={handleChange} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /increase qty/i }));
|
||||||
|
|
||||||
|
expect(handleChange).toHaveBeenCalledWith(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses typed value and calls onChange with numeric value', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
const Wrapper = () => {
|
||||||
|
const [value, setValue] = useState(2);
|
||||||
|
return (
|
||||||
|
<NumericStepper
|
||||||
|
label="Qty"
|
||||||
|
value={value}
|
||||||
|
min={1}
|
||||||
|
onChange={(val) => {
|
||||||
|
handleChange(val);
|
||||||
|
setValue(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<Wrapper />);
|
||||||
|
|
||||||
|
const input = screen.getByRole('spinbutton', { name: /qty/i });
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, '7');
|
||||||
|
|
||||||
|
expect(handleChange).toHaveBeenLastCalledWith(7);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import { ProductAutocomplete } from '../ProductAutocomplete';
|
||||||
|
import { Product } from '../../models/Product';
|
||||||
|
|
||||||
|
const mockUseCategories = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../hooks/dataHooks', () => ({
|
||||||
|
useCategories: () => mockUseCategories(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const products: Product[] = [
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
name: 'Apple',
|
||||||
|
category: 'cat-1',
|
||||||
|
unit_type: 'unit',
|
||||||
|
bulk_name: 'carton',
|
||||||
|
archived: false,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p2',
|
||||||
|
name: 'Banana',
|
||||||
|
category: 'cat-2',
|
||||||
|
unit_type: 'unit',
|
||||||
|
bulk_name: 'carton',
|
||||||
|
archived: false,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('ProductAutocomplete', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseCategories.mockReturnValue([
|
||||||
|
{ id: 'cat-1', name: 'Fruit', created_at: 0, updated_at: 0 },
|
||||||
|
{ id: 'cat-2', name: 'Pantry', created_at: 0, updated_at: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onQueryChange on input change', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onQueryChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ProductAutocomplete
|
||||||
|
availableProducts={products}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onQueryChange={onQueryChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByTestId('product-search-input');
|
||||||
|
await user.type(input, 'App');
|
||||||
|
|
||||||
|
expect(onQueryChange).toHaveBeenCalledWith('App');
|
||||||
|
expect((input as HTMLInputElement).value).toBe('App');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears query on clear reason and calls onQueryChange with empty string', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onQueryChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ProductAutocomplete
|
||||||
|
availableProducts={products}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onQueryChange={onQueryChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByTestId('product-search-input');
|
||||||
|
await user.type(input, 'Ban');
|
||||||
|
const clearButton = await screen.findByLabelText('Clear');
|
||||||
|
await user.click(clearButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onQueryChange).toHaveBeenCalledWith('');
|
||||||
|
expect((input as HTMLInputElement).value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSelect and clears selection when product chosen', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ProductAutocomplete availableProducts={products} onSelect={onSelect} onQueryChange={vi.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByTestId('product-search-input');
|
||||||
|
await user.click(input);
|
||||||
|
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
|
||||||
|
await waitFor(() => expect(onSelect).toHaveBeenCalledWith(products[0]));
|
||||||
|
expect((input as HTMLInputElement).value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables add button when handler missing', () => {
|
||||||
|
render(<ProductAutocomplete availableProducts={products} onSelect={vi.fn()} />);
|
||||||
|
const addButton = screen.getByLabelText(/add product/i);
|
||||||
|
expect(addButton).toBeDisabled();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ProductAutocomplete availableProducts={products} onSelect={vi.fn()} onAddProduct={vi.fn()} />, {
|
||||||
|
container: document.body.appendChild(document.createElement('div')),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(screen.getAllByLabelText(/add product/i).at(-1)).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import { ProductRow } from '../ProductRow';
|
||||||
|
import { makeNamedError } from '../../test/makeNamedError';
|
||||||
|
|
||||||
|
vi.mock('../BarcodeScannerView', () => ({
|
||||||
|
BarcodeScannerView: ({ onDetected }: { onDetected?: (code: string) => void }) => (
|
||||||
|
<button type="button" onClick={() => onDetected?.('scanned-code')}>
|
||||||
|
Mock Scan
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const categories = ['Snacks', 'Drinks'];
|
||||||
|
const categoriesById = new Map([
|
||||||
|
['cat-1', 'Snacks'],
|
||||||
|
['cat-2', 'Drinks'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const baseProduct = {
|
||||||
|
id: 'p1',
|
||||||
|
name: 'Product One',
|
||||||
|
category: 'cat-1',
|
||||||
|
unit_type: 'unit',
|
||||||
|
bulk_name: 'carton',
|
||||||
|
archived: false,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ProductRow error handling', () => {
|
||||||
|
let onSave: ReturnType<typeof vi.fn>;
|
||||||
|
let onDelete: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
onSave = vi.fn();
|
||||||
|
onDelete = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderRow = (productOverrides: Partial<typeof baseProduct> = {}) =>
|
||||||
|
render(
|
||||||
|
<ProductRow
|
||||||
|
product={{ ...baseProduct, ...productOverrides }}
|
||||||
|
categories={categories}
|
||||||
|
categoriesById={categoriesById}
|
||||||
|
onSave={onSave}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
it('shows helper text when onSave throws DuplicateNameError', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
onSave.mockRejectedValue(makeNamedError('DuplicateNameError'));
|
||||||
|
|
||||||
|
renderRow();
|
||||||
|
await user.click(screen.getByLabelText(/edit product one/i));
|
||||||
|
await user.clear(screen.getByLabelText(/name/i));
|
||||||
|
await user.type(screen.getByLabelText(/name/i), 'Updated');
|
||||||
|
await user.click(screen.getByLabelText(/save product/i));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('A product with this name already exists.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows barcode helper text when onSave throws DuplicateBarcodeError', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
onSave.mockRejectedValue(makeNamedError('DuplicateBarcodeError'));
|
||||||
|
|
||||||
|
renderRow({ barcode: '123' });
|
||||||
|
await user.click(screen.getByLabelText(/edit product one/i));
|
||||||
|
await user.type(screen.getByLabelText(/barcode/i), '456');
|
||||||
|
await user.click(screen.getByLabelText(/save product/i));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('This barcode is already assigned to another product.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears barcode input and toggles scanner dialog visibility', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
renderRow({ barcode: 'abc-123' });
|
||||||
|
await user.click(screen.getByLabelText(/edit product one/i));
|
||||||
|
|
||||||
|
const barcodeField = screen.getByLabelText(/barcode/i);
|
||||||
|
expect(barcodeField).toHaveValue('abc-123');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /clear/i }));
|
||||||
|
expect(barcodeField).toHaveValue('');
|
||||||
|
expect(screen.queryByText(/barcode/i, { selector: '.MuiFormHelperText-root' })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// When barcode is empty, scanner button is shown and opens dialog
|
||||||
|
await user.click(screen.getByLabelText(/cancel edit/i));
|
||||||
|
await user.click(screen.getByLabelText(/edit product one/i));
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: /scan barcode/i })).toBeInTheDocument());
|
||||||
|
await user.click(screen.getByRole('button', { name: /scan barcode/i }));
|
||||||
|
expect(screen.getByRole('dialog', { name: /scan barcode/i })).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByText(/mock scan/i));
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog', { name: /scan barcode/i })).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,8 +33,7 @@ import { PickItem } from '../models/PickItem';
|
|||||||
import { Product } from '../models/Product';
|
import { Product } from '../models/Product';
|
||||||
import { ProductAutocomplete } from '../components/ProductAutocomplete';
|
import { ProductAutocomplete } from '../components/ProductAutocomplete';
|
||||||
import { AddProductDialog } from '../components/AddProductDialog';
|
import { AddProductDialog } from '../components/AddProductDialog';
|
||||||
|
import { dedupeByIdThenNameAndSort, normalizeName } from '../utils/activePickListUtils';
|
||||||
const normalizeName = (name: string) => name.trim().toLowerCase();
|
|
||||||
|
|
||||||
const ActivePickListScreen = () => {
|
const ActivePickListScreen = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -117,42 +116,7 @@ const ActivePickListScreen = () => {
|
|||||||
[areas, pickList?.area_id],
|
[areas, pickList?.area_id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sortedProducts = useMemo(() => {
|
const sortedProducts = useMemo(() => dedupeByIdThenNameAndSort(products), [products]);
|
||||||
const dedupedById = new Map<string, Product>();
|
|
||||||
|
|
||||||
products.forEach((product: Product) => {
|
|
||||||
const existing = dedupedById.get(product.id);
|
|
||||||
if (!existing || product.updated_at > existing.updated_at) {
|
|
||||||
dedupedById.set(product.id, product);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const dedupedByName = new Map<string, Product>();
|
|
||||||
|
|
||||||
dedupedById.forEach((product: Product) => {
|
|
||||||
const normalizedName = product.name.trim().toLowerCase();
|
|
||||||
const existing = dedupedByName.get(normalizedName);
|
|
||||||
|
|
||||||
if (!existing || product.updated_at > existing.updated_at) {
|
|
||||||
dedupedByName.set(normalizedName, product);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(dedupedByName.values()).sort((a, b) => {
|
|
||||||
const normalizedNameA = normalizeName(a.name);
|
|
||||||
const normalizedNameB = normalizeName(b.name);
|
|
||||||
|
|
||||||
const nameComparison = normalizedNameA.localeCompare(normalizedNameB, undefined, {
|
|
||||||
sensitivity: 'base',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (nameComparison !== 0) {
|
|
||||||
return nameComparison;
|
|
||||||
}
|
|
||||||
|
|
||||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
|
||||||
});
|
|
||||||
}, [products]);
|
|
||||||
|
|
||||||
// Build a category id -> name map for display and name -> id map for resolution
|
// Build a category id -> name map for display and name -> id map for resolution
|
||||||
const categoriesById = useMemo(() => new Map(categoriesList.map((c) => [c.id, c.name])), [categoriesList]);
|
const categoriesById = useMemo(() => new Map(categoriesList.map((c) => [c.id, c.name])), [categoriesList]);
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
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 ManageProductsScreen from '../ManageProductsScreen';
|
||||||
|
import { createMockDb } from '../../testUtils/mockDb';
|
||||||
|
|
||||||
|
let mockDb = createMockDb();
|
||||||
|
const mockUseProducts = vi.fn();
|
||||||
|
const mockUseCategories = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../hooks/dataHooks', () => ({
|
||||||
|
useProducts: () => mockUseProducts(),
|
||||||
|
useCategories: () => mockUseCategories(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../context/DBProvider', () => ({
|
||||||
|
useDatabase: () => mockDb,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const clickSave = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||||
|
const saveButton = screen
|
||||||
|
.getAllByRole('button')
|
||||||
|
.find((btn) => /save product/i.test(btn.getAttribute('aria-label') || btn.textContent || ''));
|
||||||
|
if (!saveButton) throw new Error('Save button not found');
|
||||||
|
await user.click(saveButton);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ManageProductsScreen updateProduct', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
mockDb = createMockDb({
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
name: 'First',
|
||||||
|
category: 'cat-1',
|
||||||
|
unit_type: 'unit',
|
||||||
|
bulk_name: 'carton',
|
||||||
|
archived: false,
|
||||||
|
created_at: timestamp,
|
||||||
|
updated_at: timestamp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p2',
|
||||||
|
name: 'Second',
|
||||||
|
category: 'cat-2',
|
||||||
|
unit_type: 'unit',
|
||||||
|
bulk_name: 'carton',
|
||||||
|
archived: false,
|
||||||
|
created_at: timestamp,
|
||||||
|
updated_at: timestamp,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
categories: [
|
||||||
|
{ id: 'cat-1', name: 'Cat One', created_at: timestamp, updated_at: timestamp },
|
||||||
|
{ id: 'cat-2', name: 'Cat Two', created_at: timestamp, updated_at: timestamp },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
mockUseProducts.mockReturnValue(mockDb.products.items);
|
||||||
|
mockUseCategories.mockReturnValue(mockDb.categories.items);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rethrows DuplicateNameError for ProductRow to handle', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ManageProductsScreen />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText(/edit second/i));
|
||||||
|
await user.clear(screen.getByLabelText(/^name$/i));
|
||||||
|
await user.type(screen.getByLabelText(/^name$/i), 'First');
|
||||||
|
await clickSave(user);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByText('A product with this name already exists.'),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rethrows DuplicateBarcodeError for ProductRow to handle', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockDb.products.items[0].barcode = 'dup';
|
||||||
|
mockUseProducts.mockReturnValue([...mockDb.products.items]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ManageProductsScreen />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText(/edit second/i));
|
||||||
|
await user.type(screen.getByLabelText(/barcode/i), 'dup');
|
||||||
|
await clickSave(user);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByText('This barcode is already assigned to another product.'),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates category when missing and saves product with new category id', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const timestamp = Date.now();
|
||||||
|
mockDb = createMockDb({
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
id: 'p3',
|
||||||
|
name: 'Needs Category',
|
||||||
|
category: 'Untracked',
|
||||||
|
unit_type: 'unit',
|
||||||
|
bulk_name: 'carton',
|
||||||
|
archived: false,
|
||||||
|
created_at: timestamp,
|
||||||
|
updated_at: timestamp,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
categories: [],
|
||||||
|
});
|
||||||
|
mockUseProducts.mockReturnValue(mockDb.products.items);
|
||||||
|
mockUseCategories.mockReturnValue([]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ManageProductsScreen />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText(/edit needs category/i));
|
||||||
|
await user.type(screen.getByLabelText(/^name$/i), '!');
|
||||||
|
await clickSave(user);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockDb.categories.items.some((c) => c.name === 'Untracked')).toBe(true);
|
||||||
|
const updated = mockDb.products.items.find((p) => p.id === 'p3');
|
||||||
|
const categoryId = mockDb.categories.items.find((c) => c.name === 'Untracked')?.id;
|
||||||
|
expect(updated?.category).toBe(categoryId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export function makeNamedError(name: string, message?: string) {
|
||||||
|
const error = new Error(message ?? name);
|
||||||
|
(error as any).name = name;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { dedupeByIdThenNameAndSort, normalizeName } from '../activePickListUtils';
|
||||||
|
import { Product } from '../../models/Product';
|
||||||
|
|
||||||
|
describe('activePickListUtils', () => {
|
||||||
|
it('normalizes name by trimming and lowercasing', () => {
|
||||||
|
expect(normalizeName(' Hello ')).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupes by id, then name, and sorts', () => {
|
||||||
|
const products: Product[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Banana',
|
||||||
|
category: 'c',
|
||||||
|
unit_type: 'unit',
|
||||||
|
bulk_name: 'carton',
|
||||||
|
archived: false,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Banana newer',
|
||||||
|
category: 'c',
|
||||||
|
unit_type: 'unit',
|
||||||
|
bulk_name: 'carton',
|
||||||
|
archived: false,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: ' apple',
|
||||||
|
category: 'c',
|
||||||
|
unit_type: 'unit',
|
||||||
|
bulk_name: 'carton',
|
||||||
|
archived: false,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Apple ',
|
||||||
|
category: 'c',
|
||||||
|
unit_type: 'unit',
|
||||||
|
bulk_name: 'carton',
|
||||||
|
archived: false,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 3,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = dedupeByIdThenNameAndSort(products);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.some((p) => p.name === 'Banana newer')).toBe(true);
|
||||||
|
expect(result.find((p) => normalizeName(p.name) === 'apple')?.updated_at).toBe(3);
|
||||||
|
expect(result[0].name.toLowerCase().trim()).toBe('apple');
|
||||||
|
expect(result[1].name.toLowerCase().includes('banana')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for no products', () => {
|
||||||
|
expect(dedupeByIdThenNameAndSort([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Product } from '../models/Product';
|
||||||
|
|
||||||
|
export const normalizeName = (name: string): string => name.trim().toLowerCase();
|
||||||
|
|
||||||
|
export const dedupeByIdThenNameAndSort = (products: Product[]): Product[] => {
|
||||||
|
const dedupedById = new Map<string, Product>();
|
||||||
|
|
||||||
|
products.forEach((product) => {
|
||||||
|
const existing = dedupedById.get(product.id);
|
||||||
|
if (!existing || product.updated_at > existing.updated_at) {
|
||||||
|
dedupedById.set(product.id, product);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const dedupedByName = new Map<string, Product>();
|
||||||
|
|
||||||
|
dedupedById.forEach((product) => {
|
||||||
|
const normalizedName = normalizeName(product.name);
|
||||||
|
const existing = dedupedByName.get(normalizedName);
|
||||||
|
|
||||||
|
if (!existing || product.updated_at > existing.updated_at) {
|
||||||
|
dedupedByName.set(normalizedName, product);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(dedupedByName.values()).sort((a, b) => {
|
||||||
|
const normalizedNameA = normalizeName(a.name);
|
||||||
|
const normalizedNameB = normalizeName(b.name);
|
||||||
|
|
||||||
|
const nameComparison = normalizedNameA.localeCompare(normalizedNameB, undefined, {
|
||||||
|
sensitivity: 'base',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nameComparison !== 0) {
|
||||||
|
return nameComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { AlertColor } from '@mui/material';
|
||||||
|
|
||||||
|
export interface ActionOutcome {
|
||||||
|
text?: string;
|
||||||
|
severity?: AlertColor;
|
||||||
|
success?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackState {
|
||||||
|
text: string;
|
||||||
|
severity: AlertColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const applyOutcome = (
|
||||||
|
setFeedback: (feedback: FeedbackState) => void,
|
||||||
|
outcome: ActionOutcome | void,
|
||||||
|
defaultText: string,
|
||||||
|
defaultSeverity: AlertColor = 'success',
|
||||||
|
) => {
|
||||||
|
const success = outcome?.success ?? outcome?.severity !== 'error';
|
||||||
|
const text = outcome?.text ?? defaultText;
|
||||||
|
const severity = outcome?.severity ?? defaultSeverity;
|
||||||
|
setFeedback({ text, severity });
|
||||||
|
return success;
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Product } from '../models/Product';
|
||||||
|
|
||||||
|
export interface ProductFormState {
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
barcode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getInitialFormState = (
|
||||||
|
product: Product,
|
||||||
|
categoriesById: Map<string, string>,
|
||||||
|
): ProductFormState => ({
|
||||||
|
name: product.name,
|
||||||
|
category: categoriesById.get(product.category) ?? product.category ?? '',
|
||||||
|
barcode: product.barcode ?? '',
|
||||||
|
});
|
||||||
+9
-1
@@ -73,6 +73,14 @@ export default defineConfig({
|
|||||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: './src/test/setup.ts',
|
setupFiles: './src/test/setup.ts',
|
||||||
exclude: ['e2e/**/*']
|
exclude: ['e2e/**/*'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'lcov'],
|
||||||
|
statements: 80,
|
||||||
|
branches: 80,
|
||||||
|
functions: 80,
|
||||||
|
lines: 80,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user