Merge branch 'codex/fix-eslint-errors-in-codebase'
This commit is contained in:
+6
-8
@@ -7,17 +7,15 @@ const coverageDir = path.join(process.cwd(), 'coverage-reports', 'e2e', '.nyc_ou
|
|||||||
|
|
||||||
async function writeCoverageFile(page: Page, testInfo: TestInfo) {
|
async function writeCoverageFile(page: Page, testInfo: TestInfo) {
|
||||||
try {
|
try {
|
||||||
const coverage = await page.evaluate(() => (globalThis as any).__coverage__ ?? null);
|
const coverage = await page.evaluate(
|
||||||
|
() => (globalThis as { __coverage__?: unknown }).__coverage__ ?? null,
|
||||||
|
);
|
||||||
if (!coverage || Object.keys(coverage).length === 0) return;
|
if (!coverage || Object.keys(coverage).length === 0) return;
|
||||||
|
|
||||||
await fs.mkdir(coverageDir, { recursive: true });
|
await fs.mkdir(coverageDir, { recursive: true });
|
||||||
|
|
||||||
const titleParts =
|
const titleParts =
|
||||||
typeof testInfo.titlePath === 'function'
|
typeof testInfo.titlePath === 'function' ? testInfo.titlePath() : [testInfo.title];
|
||||||
? testInfo.titlePath()
|
|
||||||
: Array.isArray((testInfo as any).titlePath)
|
|
||||||
? (testInfo as any).titlePath
|
|
||||||
: [testInfo.title];
|
|
||||||
const safeTitle = titleParts
|
const safeTitle = titleParts
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((part) => part.replace(/[^a-zA-Z0-9-_]+/g, '_'))
|
.map((part) => part.replace(/[^a-zA-Z0-9-_]+/g, '_'))
|
||||||
@@ -35,8 +33,8 @@ async function writeCoverageFile(page: Page, testInfo: TestInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const test = base.extend({
|
export const test = base.extend({
|
||||||
page: async ({ page }, use, testInfo) => {
|
page: async ({ page }, applyPageFixture, testInfo) => {
|
||||||
await use(page);
|
await applyPageFixture(page);
|
||||||
await writeCoverageFile(page, testInfo);
|
await writeCoverageFile(page, testInfo);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test, Page } from './fixtures';
|
import { expect, test } from './fixtures';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
areaName,
|
areaName,
|
||||||
|
|||||||
@@ -50,4 +50,16 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['scripts/**/*.cjs'],
|
||||||
|
languageOptions: {
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-require-imports': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+3
-1
@@ -8,7 +8,9 @@
|
|||||||
"build": "tsc && cp src/pwa/manifest.json public/manifest.json && cp src/pwa/service-worker.js public/service-worker.js && vite build",
|
"build": "tsc && cp src/pwa/manifest.json public/manifest.json && cp src/pwa/service-worker.js public/service-worker.js && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"test:unit": "vitest",
|
"test": "vitest",
|
||||||
|
"test:unit": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"coverage:unit": "vitest run --coverage",
|
"coverage:unit": "vitest run --coverage",
|
||||||
"coverage:e2e": "node -e \"const fs=require('fs'); const path=require('path'); const target=path.join('coverage-reports','e2e'); fs.rmSync(target,{recursive:true,force:true});\" && npm run test:e2e && node scripts/remap-and-report.cjs",
|
"coverage:e2e": "node -e \"const fs=require('fs'); const path=require('path'); const target=path.join('coverage-reports','e2e'); fs.rmSync(target,{recursive:true,force:true});\" && npm run test:e2e && node scripts/remap-and-report.cjs",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig } from '@playwright/test';
|
||||||
import { defineCoverageReporterConfig } from '@bgotink/playwright-coverage';
|
import { defineCoverageReporterConfig } from '@bgotink/playwright-coverage';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { test } = require('@playwright/test');
|
const { test } = require('@playwright/test');
|
||||||
|
|
||||||
test.afterEach(async ({ page }, testInfo) => {
|
test.afterEach(async ({ page }) => {
|
||||||
try {
|
try {
|
||||||
// evaluate coverage from the page
|
// evaluate coverage from the page
|
||||||
const coverage = await page.evaluate(() => (globalThis.__coverage__ || {}));
|
const coverage = await page.evaluate(() => (globalThis.__coverage__ || {}));
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import { useDatabase } from '../context/DBProvider';
|
import { useDatabase } from '../context/DBProvider';
|
||||||
import { useProducts } from '../hooks/dataHooks';
|
import { useProducts } from '../hooks/dataHooks';
|
||||||
import { BarcodeScannerView } from './BarcodeScannerView';
|
import { BarcodeScannerView } from './BarcodeScannerView';
|
||||||
import { ExternalProductInfo, fetchProductFromOFF } from '../modules/openFoodFacts';
|
import { fetchProductFromOFF } from '../modules/openFoodFacts';
|
||||||
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE, Product } from '../models/Product';
|
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE, Product } from '../models/Product';
|
||||||
import type { BackdropProps } from '@mui/material/Backdrop';
|
import type { BackdropProps } from '@mui/material/Backdrop';
|
||||||
import type { FormHelperTextProps } from '@mui/material/FormHelperText';
|
import type { FormHelperTextProps } from '@mui/material/FormHelperText';
|
||||||
@@ -48,7 +48,6 @@ export const AddProductDialog = ({
|
|||||||
const [nameError, setNameError] = useState('');
|
const [nameError, setNameError] = useState('');
|
||||||
const [scannerOpen, setScannerOpen] = useState(false);
|
const [scannerOpen, setScannerOpen] = useState(false);
|
||||||
const [lookupStatus, setLookupStatus] = useState<'idle' | 'loading' | 'found' | 'notfound' | 'offline'>('idle');
|
const [lookupStatus, setLookupStatus] = useState<'idle' | 'loading' | 'found' | 'notfound' | 'offline'>('idle');
|
||||||
const [externalProduct, setExternalProduct] = useState<ExternalProductInfo | null>(null);
|
|
||||||
|
|
||||||
const resetForm = useCallback(() => {
|
const resetForm = useCallback(() => {
|
||||||
setName('');
|
setName('');
|
||||||
@@ -57,39 +56,9 @@ export const AddProductDialog = ({
|
|||||||
setBarcodeError('');
|
setBarcodeError('');
|
||||||
setNameError('');
|
setNameError('');
|
||||||
setLookupStatus('idle');
|
setLookupStatus('idle');
|
||||||
setExternalProduct(null);
|
|
||||||
setScannerOpen(false);
|
setScannerOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
if (categoryOptions.length > 0 && !categoryOptions.includes(category)) {
|
|
||||||
setCategory(categoryOptions[0]);
|
|
||||||
}
|
|
||||||
if (initialBarcode) {
|
|
||||||
setBarcode(initialBarcode);
|
|
||||||
void lookupBarcode(initialBarcode);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
resetForm();
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [open, categoryOptions, initialBarcode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !barcode) return;
|
|
||||||
void lookupBarcode(barcode);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [open, barcode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!barcode) {
|
|
||||||
setLookupStatus('idle');
|
|
||||||
setExternalProduct(null);
|
|
||||||
}
|
|
||||||
setBarcodeError('');
|
|
||||||
}, [barcode]);
|
|
||||||
|
|
||||||
const categoryMap = useMemo(() => new Map(categoryOptions.map((c) => [c, c])), [categoryOptions]);
|
const categoryMap = useMemo(() => new Map(categoryOptions.map((c) => [c, c])), [categoryOptions]);
|
||||||
|
|
||||||
const findBarcodeConflict = useCallback(
|
const findBarcodeConflict = useCallback(
|
||||||
@@ -185,26 +154,49 @@ export const AddProductDialog = ({
|
|||||||
[db.pickItems, db.pickLists, db.categories],
|
[db.pickItems, db.pickLists, db.categories],
|
||||||
);
|
);
|
||||||
|
|
||||||
async function lookupBarcode(code: string) {
|
const lookupBarcode = useCallback(async (code: string) => {
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
if (typeof navigator !== 'undefined' && 'onLine' in navigator && navigator.onLine === false) {
|
if (typeof navigator !== 'undefined' && 'onLine' in navigator && navigator.onLine === false) {
|
||||||
setLookupStatus('offline');
|
setLookupStatus('offline');
|
||||||
setExternalProduct(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLookupStatus('loading');
|
setLookupStatus('loading');
|
||||||
const result = await fetchProductFromOFF(code);
|
const result = await fetchProductFromOFF(code);
|
||||||
if (result) {
|
if (result) {
|
||||||
setExternalProduct(result);
|
|
||||||
setLookupStatus('found');
|
setLookupStatus('found');
|
||||||
if (result.name) {
|
if (result.name) {
|
||||||
setName(result.name || '');
|
setName(result.name || '');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setExternalProduct(null);
|
|
||||||
setLookupStatus('notfound');
|
setLookupStatus('notfound');
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (categoryOptions.length > 0 && !categoryOptions.includes(category)) {
|
||||||
|
setCategory(categoryOptions[0]);
|
||||||
}
|
}
|
||||||
|
if (initialBarcode) {
|
||||||
|
setBarcode(initialBarcode);
|
||||||
|
void lookupBarcode(initialBarcode);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
}, [category, categoryOptions, initialBarcode, lookupBarcode, open, resetForm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !barcode) return;
|
||||||
|
void lookupBarcode(barcode);
|
||||||
|
}, [barcode, lookupBarcode, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!barcode) {
|
||||||
|
setLookupStatus('idle');
|
||||||
|
}
|
||||||
|
setBarcodeError('');
|
||||||
|
}, [barcode]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setNameError('');
|
setNameError('');
|
||||||
@@ -253,16 +245,17 @@ export const AddProductDialog = ({
|
|||||||
onFeedback?.({ text: 'Product added.', severity: 'success' });
|
onFeedback?.({ text: 'Product added.', severity: 'success' });
|
||||||
resetForm();
|
resetForm();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
if (err?.name === 'DuplicateNameError') {
|
if (err instanceof Error && err.name === 'DuplicateNameError') {
|
||||||
setNameError(err.message || 'A product with this name already exists.');
|
setNameError(err.message || 'A product with this name already exists.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (err?.name === 'DuplicateBarcodeError') {
|
if (err instanceof Error && err.name === 'DuplicateBarcodeError') {
|
||||||
setBarcodeError(err.message || 'This barcode is already assigned to another product.');
|
setBarcodeError(err.message || 'This barcode is already assigned to another product.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onFeedback?.({ text: `Failed to add product: ${err?.message ?? String(err)}`, severity: 'error' });
|
const fallbackMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
onFeedback?.({ text: `Failed to add product: ${fallbackMessage}`, severity: 'error' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import DeleteIcon from '@mui/icons-material/Delete';
|
|||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AlertColor,
|
|
||||||
Button,
|
Button,
|
||||||
IconButton,
|
IconButton,
|
||||||
List,
|
List,
|
||||||
@@ -70,7 +69,8 @@ export const EditableEntityList = ({
|
|||||||
setNewName('');
|
setNewName('');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFeedback({ text: `Unable to add ${entityLabel.toLowerCase()}.`, severity: 'error' });
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setFeedback({ text: `Unable to add ${entityLabel.toLowerCase()}: ${message}`, severity: 'error' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,7 +97,8 @@ export const EditableEntityList = ({
|
|||||||
cancelEditing();
|
cancelEditing();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFeedback({ text: `Unable to update ${entityLabel.toLowerCase()}.`, severity: 'error' });
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setFeedback({ text: `Unable to update ${entityLabel.toLowerCase()}: ${message}`, severity: 'error' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,7 +116,8 @@ export const EditableEntityList = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFeedback({ text: `Unable to delete ${entityLabel.toLowerCase()}.`, severity: 'error' });
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setFeedback({ text: `Unable to delete ${entityLabel.toLowerCase()}: ${message}`, severity: 'error' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// src/components/ProductRow.additional.test.tsx
|
// src/components/ProductRow.additional.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
import { render, screen, waitFor, cleanup } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// src/db/migrations.ts
|
// src/db/migrations.ts
|
||||||
import { StockFillDB } from './index';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import type { PickList } from '../models/PickList';
|
||||||
|
import { StockFillDB } from './index';
|
||||||
|
|
||||||
export const applyMigrations = async (db: StockFillDB) => {
|
export const applyMigrations = async (db: StockFillDB) => {
|
||||||
// Ensure DB is open and ready
|
// Ensure DB is open and ready
|
||||||
@@ -58,9 +59,10 @@ export const applyMigrations = async (db: StockFillDB) => {
|
|||||||
const pickLists = await db.pickLists.toArray();
|
const pickLists = await db.pickLists.toArray();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
pickLists.map(async (pl) => {
|
pickLists.map(async (pl) => {
|
||||||
if (!Array.isArray((pl as any).categories)) return;
|
const categories = (pl as PickList).categories;
|
||||||
|
if (!Array.isArray(categories)) return;
|
||||||
|
|
||||||
const newCats = (pl as any).categories.map((entry: string) => {
|
const newCats = categories.map((entry: string) => {
|
||||||
// If the entry is already an id we know, keep it
|
// If the entry is already an id we know, keep it
|
||||||
if (updatedById.has(entry)) return entry;
|
if (updatedById.has(entry)) return entry;
|
||||||
// If entry is a name, return its id (if exists)
|
// If entry is a name, return its id (if exists)
|
||||||
@@ -71,7 +73,7 @@ export const applyMigrations = async (db: StockFillDB) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update only if changed
|
// Update only if changed
|
||||||
if (JSON.stringify(newCats) !== JSON.stringify((pl as any).categories)) {
|
if (JSON.stringify(newCats) !== JSON.stringify(categories)) {
|
||||||
await db.pickLists.update(pl.id, { categories: newCats });
|
await db.pickLists.update(pl.id, { categories: newCats });
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// src/screens/ActivePickListScreen.additional.test.tsx
|
// src/screens/ActivePickListScreen.additional.test.tsx
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||||
import { render, screen, within, waitFor, cleanup } from '@testing-library/react';
|
import { render, screen, waitFor, cleanup } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
import ActivePickListScreen from './ActivePickListScreen';
|
import ActivePickListScreen from './ActivePickListScreen';
|
||||||
import { PickItem } from '../models/PickItem';
|
import { PickItem } from '../models/PickItem';
|
||||||
import { Product } from '../models/Product';
|
import { Product } from '../models/Product';
|
||||||
|
import { Category } from '../models/Category';
|
||||||
|
|
||||||
const addMock = vi.fn();
|
const addMock = vi.fn();
|
||||||
const updateMock = vi.fn();
|
const updateMock = vi.fn();
|
||||||
@@ -15,7 +16,7 @@ const productsMock = vi.fn<() => Product[]>();
|
|||||||
const pickListMock = vi.fn();
|
const pickListMock = vi.fn();
|
||||||
|
|
||||||
// allow tests to mutate categories returned by the mocked hook
|
// allow tests to mutate categories returned by the mocked hook
|
||||||
let categoriesVar: any[] = [{ id: 'cat-1', name: 'Drinks', created_at: 0, updated_at: 0 }];
|
let categoriesVar: Category[] = [{ id: 'cat-1', name: 'Drinks', created_at: 0, updated_at: 0 }];
|
||||||
|
|
||||||
vi.mock('../hooks/dataHooks', () => ({
|
vi.mock('../hooks/dataHooks', () => ({
|
||||||
usePickItems: () => pickItemsMock(),
|
usePickItems: () => pickItemsMock(),
|
||||||
@@ -35,7 +36,7 @@ vi.mock('../context/DBProvider', () => ({
|
|||||||
return items.find((it) => it.id === id);
|
return items.find((it) => it.id === id);
|
||||||
}),
|
}),
|
||||||
delete: deleteMock,
|
delete: deleteMock,
|
||||||
where: (_f: string) => ({
|
where: () => ({
|
||||||
equals: (val: any) => ({
|
equals: (val: any) => ({
|
||||||
toArray: async () => {
|
toArray: async () => {
|
||||||
const items: PickItem[] = pickItemsMock() ?? [];
|
const items: PickItem[] = pickItemsMock() ?? [];
|
||||||
@@ -96,12 +97,6 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('ActivePickListScreen - additional branches', () => {
|
describe('ActivePickListScreen - additional branches', () => {
|
||||||
const getProductInput = () => {
|
|
||||||
const byTestId = screen.queryByTestId('product-search-input');
|
|
||||||
if (byTestId) return byTestId;
|
|
||||||
return screen.getByPlaceholderText('Search products');
|
|
||||||
};
|
|
||||||
|
|
||||||
it('increments / decrements / toggles carton / toggles status / deletes an item', async () => {
|
it('increments / decrements / toggles carton / toggles status / deletes an item', async () => {
|
||||||
pickItemsMock.mockReturnValue([
|
pickItemsMock.mockReturnValue([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ vi.mock('../context/DBProvider', () => ({
|
|||||||
update: updateMock,
|
update: updateMock,
|
||||||
get: vi.fn(async (id: string) => (pickItemsMock() || []).find((it) => it.id === id)),
|
get: vi.fn(async (id: string) => (pickItemsMock() || []).find((it) => it.id === id)),
|
||||||
delete: deleteMock,
|
delete: deleteMock,
|
||||||
where: (_f: string) => ({
|
where: () => ({
|
||||||
equals: (val: any) => ({
|
equals: (val: any) => ({
|
||||||
toArray: async () => (pickItemsMock() || []).filter((it) => it.pick_list_id === val),
|
toArray: async () => (pickItemsMock() || []).filter((it) => it.pick_list_id === val),
|
||||||
count: async () => (pickItemsMock() || []).filter((it) => it.pick_list_id === val).length,
|
count: async () => (pickItemsMock() || []).filter((it) => it.pick_list_id === val).length,
|
||||||
|
|||||||
@@ -79,17 +79,6 @@ describe('ActivePickListScreen product search', () => {
|
|||||||
* - otherwise fall back to placeholder 'Search products'.
|
* - otherwise fall back to placeholder 'Search products'.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
// helper to get the packaging radio input. Tests were expecting to call .querySelector('input')
|
|
||||||
// on a wrapper with data-testid; preserve that behavior but return the actual radio element.
|
|
||||||
const getPackagingRadioInput = (testId: 'packaging-filter-all' | 'packaging-filter-units' | 'packaging-filter-cartons') => {
|
|
||||||
const wrapper = screen.getByTestId(testId);
|
|
||||||
// FormControlLabel renders the input nested — find it
|
|
||||||
const input = (wrapper as HTMLElement).querySelector('input');
|
|
||||||
if (!input) throw new Error(`Could not find input inside ${testId}`);
|
|
||||||
return input;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Keep the original getRadio shape for minimal change
|
// Keep the original getRadio shape for minimal change
|
||||||
const getRadio = (testId: string) => {
|
const getRadio = (testId: string) => {
|
||||||
// try testid wrapper -> radio inside, otherwise find radio by label name
|
// try testid wrapper -> radio inside, otherwise find radio by label name
|
||||||
@@ -137,8 +126,6 @@ describe('ActivePickListScreen product search', () => {
|
|||||||
await user.click(combobox);
|
await user.click(combobox);
|
||||||
await user.type(combobox, 'cola');
|
await user.type(combobox, 'cola');
|
||||||
|
|
||||||
const listbox = await screen.findByRole('listbox');
|
|
||||||
|
|
||||||
expect(await screen.findByRole('option', { name: /cola \(drinks\)/i })).toBeVisible();
|
expect(await screen.findByRole('option', { name: /cola \(drinks\)/i })).toBeVisible();
|
||||||
expect(screen.queryByRole('option', { name: /chips \(snacks\)/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('option', { name: /chips \(snacks\)/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -489,7 +489,7 @@ const ActivePickListScreen = () => {
|
|||||||
SelectProps={{ native: true }}
|
SelectProps={{ native: true }}
|
||||||
label="Filter by category"
|
label="Filter by category"
|
||||||
value={categoryFilter}
|
value={categoryFilter}
|
||||||
onChange={(e) => setCategoryFilter((e.target.value as any) ?? 'all')}
|
onChange={(e) => setCategoryFilter(e.target.value || 'all')}
|
||||||
sx={{ width: { xs: '100%', sm: 240 }, ml: { xs: 0, sm: 2 }, mt: { xs: 1, sm: 0 } }}
|
sx={{ width: { xs: '100%', sm: 240 }, ml: { xs: 0, sm: 2 }, mt: { xs: 1, sm: 0 } }}
|
||||||
>
|
>
|
||||||
<option value="all">All categories</option>
|
<option value="all">All categories</option>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const ManageAreasScreen = () => {
|
|||||||
entities={areas.map((area) => ({ id: area.id, name: area.name }))}
|
entities={areas.map((area) => ({ id: area.id, name: area.name }))}
|
||||||
onAdd={addArea}
|
onAdd={addArea}
|
||||||
onUpdate={saveArea}
|
onUpdate={saveArea}
|
||||||
onDelete={(areaId, areaName) => deleteArea(areaId)}
|
onDelete={(areaId) => deleteArea(areaId)}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ vi.mock('../context/DBProvider', () => ({
|
|||||||
categories: { add: categoryAddMock, update: categoryUpdateMock, delete: categoryDeleteMock },
|
categories: { add: categoryAddMock, update: categoryUpdateMock, delete: categoryDeleteMock },
|
||||||
products: {
|
products: {
|
||||||
where: () => ({
|
where: () => ({
|
||||||
equals: (_: string) => ({
|
equals: () => ({
|
||||||
count: async () => 0,
|
count: async () => 0,
|
||||||
modify: async (changes: any) => productModifyMock(changes),
|
modify: async (changes: any) => productModifyMock(changes),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import { ActionOutcome, EditableEntityList } from '../components/EditableEntityList';
|
import { ActionOutcome, EditableEntityList } from '../components/EditableEntityList';
|
||||||
import { useDatabase } from '../context/DBProvider';
|
import { useDatabase } from '../context/DBProvider';
|
||||||
import { useCategories, useProducts } from '../hooks/dataHooks';
|
import { useCategories, useProducts } from '../hooks/dataHooks';
|
||||||
|
import type { PickList } from '../models/PickList';
|
||||||
|
|
||||||
const ManageCategoriesScreen = () => {
|
const ManageCategoriesScreen = () => {
|
||||||
const db = useDatabase();
|
const db = useDatabase();
|
||||||
@@ -55,10 +56,11 @@ const ManageCategoriesScreen = () => {
|
|||||||
const pickLists = await db.pickLists.toArray();
|
const pickLists = await db.pickLists.toArray();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
pickLists.map(async (pickList) => {
|
pickLists.map(async (pickList) => {
|
||||||
if (!Array.isArray((pickList as any).categories)) return;
|
const pickListCategories = (pickList as PickList).categories;
|
||||||
const needsUpdate = (pickList as any).categories.includes(category.name);
|
if (!Array.isArray(pickListCategories)) return;
|
||||||
|
const needsUpdate = pickListCategories.includes(category.name);
|
||||||
if (!needsUpdate) return;
|
if (!needsUpdate) return;
|
||||||
const updatedCategories = (pickList as any).categories.map((existing: string) =>
|
const updatedCategories = pickListCategories.map((existing: string) =>
|
||||||
existing === category.name ? trimmed : existing,
|
existing === category.name ? trimmed : existing,
|
||||||
);
|
);
|
||||||
await db.pickLists.update(pickList.id, { categories: updatedCategories });
|
await db.pickLists.update(pickList.id, { categories: updatedCategories });
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ describe('ManageProductsScreen update product category creation branch', () => {
|
|||||||
productsDb.get.mockResolvedValue(existingProduct);
|
productsDb.get.mockResolvedValue(existingProduct);
|
||||||
|
|
||||||
// products.where('barcode').equals(value).first() must exist for uniqueness check
|
// products.where('barcode').equals(value).first() must exist for uniqueness check
|
||||||
productsDb.where.mockImplementation((field?: string) => ({
|
productsDb.where.mockImplementation(() => ({
|
||||||
equals: (value?: string) => ({
|
equals: () => ({
|
||||||
first: async () => undefined,
|
first: async () => undefined,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -483,7 +483,7 @@ describe('ManageProductsScreen auto-adding products to pick lists', () => {
|
|||||||
// ensure the categories DB has a 'Snacks' row
|
// ensure the categories DB has a 'Snacks' row
|
||||||
mockDb.categories.toArray.mockResolvedValue([{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }]);
|
mockDb.categories.toArray.mockResolvedValue([{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }]);
|
||||||
mockDb.categories.where.mockImplementation(() => ({
|
mockDb.categories.where.mockImplementation(() => ({
|
||||||
equals: (value: string) => ({
|
equals: () => ({
|
||||||
first: async () => ({ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }),
|
first: async () => ({ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ const ManageProductsScreen = () => {
|
|||||||
setPendingBarcode(state.newBarcode);
|
setPendingBarcode(state.newBarcode);
|
||||||
setAddProductDialogOpen(true);
|
setAddProductDialogOpen(true);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [location.state]);
|
}, [location.state]);
|
||||||
|
|
||||||
const updateProduct = async (
|
const updateProduct = async (
|
||||||
@@ -172,14 +171,15 @@ const ManageProductsScreen = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
setFeedback({ text: 'Product updated.', severity: 'success' });
|
setFeedback({ text: 'Product updated.', severity: 'success' });
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to update product', err);
|
console.error('Failed to update product', err);
|
||||||
|
|
||||||
if (err?.name === 'DuplicateNameError' || err?.name === 'DuplicateBarcodeError') {
|
if (err instanceof Error && (err.name === 'DuplicateNameError' || err.name === 'DuplicateBarcodeError')) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFeedback({ text: `Failed to update product: ${err?.message ?? String(err)}`, severity: 'error' });
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setFeedback({ text: `Failed to update product: ${message}`, severity: 'error' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// src/services/importExportService.additional.test.ts
|
// src/services/importExportService.additional.test.ts
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { importFiles } from './importExportService';
|
import { importFiles } from './importExportService';
|
||||||
import { createMockDb } from '../testUtils/mockDb';
|
import { createMockDb } from '../testUtils/mockDb';
|
||||||
|
|||||||
@@ -3,13 +3,8 @@ import Papa from 'papaparse';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { StockFillDB } from '../db';
|
import { StockFillDB } from '../db';
|
||||||
import { ImportExportLog, ImportExportLogSummary } from '../models/ImportExportLog';
|
import { ImportExportLog, ImportExportLogSummary } from '../models/ImportExportLog';
|
||||||
import { Area } from '../models/Area';
|
|
||||||
import { Category } from '../models/Category';
|
|
||||||
import { PickItem, PickItemStatus } from '../models/PickItem';
|
|
||||||
import { PickList } from '../models/PickList';
|
|
||||||
import { Product, DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE } from '../models/Product';
|
import { Product, DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE } from '../models/Product';
|
||||||
import { normalizeName, inferTypeFromName } from '../utils/stringUtils';
|
import { normalizeName, inferTypeFromName } from '../utils/stringUtils';
|
||||||
import { coerceBoolean, coerceNumber } from '../utils/convUtils';
|
|
||||||
import { triggerDownload } from '../platform/web';
|
import { triggerDownload } from '../platform/web';
|
||||||
export { normalizeName, inferTypeFromName } from '../utils/stringUtils';
|
export { normalizeName, inferTypeFromName } from '../utils/stringUtils';
|
||||||
export { coerceBoolean, coerceNumber } from '../utils/convUtils';
|
export { coerceBoolean, coerceNumber } from '../utils/convUtils';
|
||||||
@@ -56,7 +51,6 @@ const readFiles = async (files: File[]) => {
|
|||||||
if (file.name.toLowerCase().endsWith('.zip')) {
|
if (file.name.toLowerCase().endsWith('.zip')) {
|
||||||
const zip = await JSZip.loadAsync(file);
|
const zip = await JSZip.loadAsync(file);
|
||||||
const entries = Object.values(zip.files).filter((entry) => !entry.dir);
|
const entries = Object.values(zip.files).filter((entry) => !entry.dir);
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const content = await entry.async('string');
|
const content = await entry.async('string');
|
||||||
parsed.push({ name: entry.name, content });
|
parsed.push({ name: entry.name, content });
|
||||||
@@ -244,15 +238,15 @@ export const importFiles = async (
|
|||||||
|
|
||||||
const parsedRows: Partial<Record<DataType, Record<string, string>[]>> = {};
|
const parsedRows: Partial<Record<DataType, Record<string, string>[]>> = {};
|
||||||
|
|
||||||
let productRows: Record<string, string>[] = [];
|
const productRows: Record<string, string>[] = [];
|
||||||
|
|
||||||
for (const file of parsedFiles) {
|
for (const file of parsedFiles) {
|
||||||
const rows = await parseCsv(file.content);
|
const rows = await parseCsv(file.content);
|
||||||
addDetail(`Parsed ${rows.length} rows from ${file.name}`);
|
addDetail(`Parsed ${rows.length} rows from ${file.name}`);
|
||||||
if (!rows || rows.length === 0) continue;
|
if (!rows || rows.length === 0) continue;
|
||||||
|
|
||||||
// Look at headers of first row to decide if it's product-centric
|
// Look at headers of first row to decide if it's product-centric
|
||||||
const headerKeys = Object.keys(rows[0]).map(h => h.trim().toLowerCase());
|
const headerKeys = Object.keys(rows[0]).map((header) => header.trim().toLowerCase());
|
||||||
const isProductCentric =
|
const isProductCentric =
|
||||||
headerKeys.includes('category') && (headerKeys.includes('name') || headerKeys.includes('product_name'));
|
headerKeys.includes('category') && (headerKeys.includes('name') || headerKeys.includes('product_name'));
|
||||||
|
|
||||||
@@ -306,11 +300,8 @@ for (const file of parsedFiles) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db.transaction(
|
await db.transaction('rw', [db.categories, db.products], async () => {
|
||||||
'rw',
|
if (productRows.length > 0) {
|
||||||
[db.categories, db.products],
|
|
||||||
async () => {
|
|
||||||
if (productRows.length > 0) {
|
|
||||||
// 1) Make a unique list of category names from CSV (normalized)
|
// 1) Make a unique list of category names from CSV (normalized)
|
||||||
const uniqueCategoryNames = new Map<string, string>(); // normalized -> raw
|
const uniqueCategoryNames = new Map<string, string>(); // normalized -> raw
|
||||||
for (const row of productRows) {
|
for (const row of productRows) {
|
||||||
@@ -378,10 +369,8 @@ if (productRows.length > 0) {
|
|||||||
inserted += 1;
|
inserted += 1;
|
||||||
addDetail(`Created product "${product.name}"`);
|
addDetail(`Created product "${product.name}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors += 1;
|
errors += 1;
|
||||||
addDetail(`Import failed: ${(error as Error).message}`);
|
addDetail(`Import failed: ${(error as Error).message}`);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export function makeNamedError(name: string, message?: string) {
|
export function makeNamedError(name: string, message?: string) {
|
||||||
const error = new Error(message ?? name);
|
const error = new Error(message ?? name);
|
||||||
(error as any).name = name;
|
error.name = name;
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-11
@@ -4,6 +4,7 @@ import type { Category } from '../models/Category';
|
|||||||
import type { Area } from '../models/Area';
|
import type { Area } from '../models/Area';
|
||||||
import type { PickList } from '../models/PickList';
|
import type { PickList } from '../models/PickList';
|
||||||
import type { PickItem } from '../models/PickItem';
|
import type { PickItem } from '../models/PickItem';
|
||||||
|
import type { ImportExportLog } from '../models/ImportExportLog';
|
||||||
|
|
||||||
export class MockTable<T extends { id: string }> {
|
export class MockTable<T extends { id: string }> {
|
||||||
items: T[];
|
items: T[];
|
||||||
@@ -39,19 +40,19 @@ export class MockTable<T extends { id: string }> {
|
|||||||
this.items = this.items.filter((i) => i.id !== id);
|
this.items = this.items.filter((i) => i.id !== id);
|
||||||
}
|
}
|
||||||
|
|
||||||
where(field: string) {
|
where<K extends keyof T>(field: K) {
|
||||||
return {
|
return {
|
||||||
equals: (val: any) => ({
|
equals: (val: T[K]) => ({
|
||||||
first: async () => this.items.find((it: any) => it[field] === val),
|
first: async () => this.items.find((it) => it[field] === val),
|
||||||
count: async () => this.items.filter((it: any) => it[field] === val).length,
|
count: async () => this.items.filter((it) => it[field] === val).length,
|
||||||
filter: (pred: (it: any) => boolean) => ({
|
filter: (pred: (it: T) => boolean) => ({
|
||||||
first: async () => this.items.find((it: any) => it[field] === val && pred(it)),
|
first: async () => this.items.find((it) => it[field] === val && pred(it)),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
filter(pred: (it: any) => boolean) {
|
filter(pred: (it: T) => boolean) {
|
||||||
const filtered = this.items.filter(pred);
|
const filtered = this.items.filter(pred);
|
||||||
return {
|
return {
|
||||||
delete: async () => {
|
delete: async () => {
|
||||||
@@ -68,7 +69,7 @@ export const createMockDb = (data?: {
|
|||||||
areas?: Area[];
|
areas?: Area[];
|
||||||
pickLists?: PickList[];
|
pickLists?: PickList[];
|
||||||
pickItems?: PickItem[];
|
pickItems?: PickItem[];
|
||||||
importExportLogs?: any[];
|
importExportLogs?: ImportExportLog[];
|
||||||
}) => {
|
}) => {
|
||||||
return {
|
return {
|
||||||
products: new MockTable<Product>(data?.products ?? []),
|
products: new MockTable<Product>(data?.products ?? []),
|
||||||
@@ -76,9 +77,12 @@ export const createMockDb = (data?: {
|
|||||||
areas: new MockTable<Area>(data?.areas ?? []),
|
areas: new MockTable<Area>(data?.areas ?? []),
|
||||||
pickLists: new MockTable<PickList>(data?.pickLists ?? []),
|
pickLists: new MockTable<PickList>(data?.pickLists ?? []),
|
||||||
pickItems: new MockTable<PickItem>(data?.pickItems ?? []),
|
pickItems: new MockTable<PickItem>(data?.pickItems ?? []),
|
||||||
importExportLogs: new MockTable<any>(data?.importExportLogs ?? []),
|
importExportLogs: new MockTable<ImportExportLog>(data?.importExportLogs ?? []),
|
||||||
transaction: async (_mode: string, ...args: any[]) => {
|
transaction: async (
|
||||||
const cb = args[args.length - 1];
|
_mode: string,
|
||||||
|
...args: Array<MockTable<{ id: string }> | (() => unknown)>
|
||||||
|
) => {
|
||||||
|
const cb = args.at(-1);
|
||||||
if (typeof cb === 'function') return cb();
|
if (typeof cb === 'function') return cb();
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
export const stubDownloads = (vi: any) => {
|
type VitestMocker = typeof import('vitest')['vi'];
|
||||||
const anchor = { href: '', download: '', click: vi.fn() } as any;
|
|
||||||
|
export const stubDownloads = (vi: VitestMocker) => {
|
||||||
|
const anchor: { href: string; download: string; click: ReturnType<VitestMocker['fn']> } = {
|
||||||
|
href: '',
|
||||||
|
download: '',
|
||||||
|
click: vi.fn(),
|
||||||
|
};
|
||||||
const createObjectURL = vi.fn(() => 'blob:url');
|
const createObjectURL = vi.fn(() => 'blob:url');
|
||||||
const revokeObjectURL = vi.fn();
|
const revokeObjectURL = vi.fn();
|
||||||
vi.stubGlobal('document', { createElement: () => anchor });
|
vi.stubGlobal('document', { createElement: () => anchor });
|
||||||
|
|||||||
Reference in New Issue
Block a user