Fix lint errors

This commit is contained in:
beatz174-bit
2025-12-02 17:22:09 +10:00
parent f6edda1c42
commit 9822cad8eb
24 changed files with 171 additions and 181 deletions
+6 -8
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import { expect, test, Page } from './fixtures'; import { expect, test } from './fixtures';
import { import {
areaName, areaName,
+12
View File
@@ -50,4 +50,16 @@ export default tseslint.config(
}, },
}, },
}, },
{
files: ['scripts/**/*.cjs'],
languageOptions: {
sourceType: 'commonjs',
globals: {
...globals.node,
},
},
rules: {
'@typescript-eslint/no-require-imports': 'off',
},
},
); );
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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__ || {}));
+10 -17
View File
@@ -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,7 +56,6 @@ export const AddProductDialog = ({
setBarcodeError(''); setBarcodeError('');
setNameError(''); setNameError('');
setLookupStatus('idle'); setLookupStatus('idle');
setExternalProduct(null);
setScannerOpen(false); setScannerOpen(false);
}, []); }, []);
@@ -73,19 +71,16 @@ export const AddProductDialog = ({
} else { } else {
resetForm(); resetForm();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [category, categoryOptions, initialBarcode, lookupBarcode, open, resetForm]);
}, [open, categoryOptions, initialBarcode]);
useEffect(() => { useEffect(() => {
if (!open || !barcode) return; if (!open || !barcode) return;
void lookupBarcode(barcode); void lookupBarcode(barcode);
// eslint-disable-next-line react-hooks/exhaustive-deps }, [barcode, lookupBarcode, open]);
}, [open, barcode]);
useEffect(() => { useEffect(() => {
if (!barcode) { if (!barcode) {
setLookupStatus('idle'); setLookupStatus('idle');
setExternalProduct(null);
} }
setBarcodeError(''); setBarcodeError('');
}, [barcode]); }, [barcode]);
@@ -185,26 +180,23 @@ 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');
} }
} }, []);
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' });
} }
}; };
+6 -4
View File
@@ -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';
+6 -4
View File
@@ -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,
-13
View File
@@ -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();
}); });
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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),
}), }),
+5 -3
View File
@@ -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,
}), }),
})); }));
+1 -1
View File
@@ -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 }),
}), }),
})); }));
+4 -4
View File
@@ -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';
+82 -93
View File
@@ -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,25 +238,25 @@ 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'));
if (isProductCentric) { if (isProductCentric) {
// Collect product rows for product-centric import // Collect product rows for product-centric import
productRows.push(...rows); productRows.push(...rows);
// record that we detected products (for logging later) // record that we detected products (for logging later)
if (!selectedTypes.includes('products')) selectedTypes.push('products'); if (!selectedTypes.includes('products')) selectedTypes.push('products');
} else { } else {
// Fallback to filename-based inference for older templates // Fallback to filename-based inference for older templates
const type = inferTypeFromName(file.name) as DataType | undefined; const type = inferTypeFromName(file.name) as DataType | undefined;
if (!type) { if (!type) {
addDetail(`Skipped ${file.name}: not product-centric and could not infer data type`); addDetail(`Skipped ${file.name}: not product-centric and could not infer data type`);
@@ -306,82 +300,77 @@ 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], // 1) Make a unique list of category names from CSV (normalized)
async () => { const uniqueCategoryNames = new Map<string, string>(); // normalized -> raw
if (productRows.length > 0) { for (const row of productRows) {
// 1) Make a unique list of category names from CSV (normalized) const rawCat = (row.category ?? '').trim();
const uniqueCategoryNames = new Map<string, string>(); // normalized -> raw if (!rawCat) continue;
for (const row of productRows) { const norm = normalizeName(rawCat);
const rawCat = (row.category ?? '').trim(); if (norm && !uniqueCategoryNames.has(norm)) uniqueCategoryNames.set(norm, rawCat);
if (!rawCat) continue; }
const norm = normalizeName(rawCat);
if (norm && !uniqueCategoryNames.has(norm)) uniqueCategoryNames.set(norm, rawCat);
}
// 2) Create missing categories in DB // 2) Create missing categories in DB
for (const [norm, raw] of uniqueCategoryNames) { for (const [norm, raw] of uniqueCategoryNames) {
if (!categoryNameToId.has(norm)) { if (!categoryNameToId.has(norm)) {
const newId = uuidv4(); const newId = uuidv4();
categoryNameToId.set(norm, newId); categoryNameToId.set(norm, newId);
await db.categories.add({ await db.categories.add({
id: newId, id: newId,
name: raw, name: raw,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
addDetail(`Created category "${raw}"`); addDetail(`Created category "${raw}"`);
} }
} }
// 3) Create products, linking to categories // 3) Create products, linking to categories
for (const row of productRows) { for (const row of productRows) {
const nameRaw = (row.product_name ?? row.name ?? '').trim(); const nameRaw = (row.product_name ?? row.name ?? '').trim();
const name = normalizeName(nameRaw); const name = normalizeName(nameRaw);
if (!name) { if (!name) {
addDetail('Skipped product with empty name'); addDetail('Skipped product with empty name');
skipped += 1; skipped += 1;
continue; continue;
} }
if (productNameToId.has(name)) { if (productNameToId.has(name)) {
addDetail(`Product "${nameRaw}" exists, skipping`); addDetail(`Product "${nameRaw}" exists, skipping`);
skipped += 1; skipped += 1;
continue; continue;
} }
const catRaw = (row.category ?? '').trim(); const catRaw = (row.category ?? '').trim();
const categoryId = catRaw ? categoryNameToId.get(normalizeName(catRaw)) : undefined; const categoryId = catRaw ? categoryNameToId.get(normalizeName(catRaw)) : undefined;
const barcode = row.barcode?.trim(); const barcode = row.barcode?.trim();
if (barcode && barcodeToProductId.has(barcode)) { if (barcode && barcodeToProductId.has(barcode)) {
addDetail(`Barcode ${barcode} already exists, clearing for product "${nameRaw}"`); addDetail(`Barcode ${barcode} already exists, clearing for product "${nameRaw}"`);
} }
const id = uuidv4(); const id = uuidv4();
const product: Product = { const product: Product = {
id, id,
name: nameRaw, name: nameRaw,
category: categoryId ?? '', category: categoryId ?? '',
unit_type: DEFAULT_UNIT_TYPE, unit_type: DEFAULT_UNIT_TYPE,
bulk_name: DEFAULT_BULK_NAME, bulk_name: DEFAULT_BULK_NAME,
barcode: barcode && !barcodeToProductId.has(barcode) ? barcode : undefined, barcode: barcode && !barcodeToProductId.has(barcode) ? barcode : undefined,
archived: false, archived: false,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
await db.products.add(product); await db.products.add(product);
productNameToId.set(name, id); productNameToId.set(name, id);
if (product.barcode) { if (product.barcode) {
barcodeToProductId.set(product.barcode, id); barcodeToProductId.set(product.barcode, id);
} }
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 -1
View File
@@ -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
View File
@@ -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<unknown> | (() => unknown)>
) => {
const cb = args.at(-1);
if (typeof cb === 'function') return cb(); if (typeof cb === 'function') return cb();
return undefined; return undefined;
}, },
+8 -2
View File
@@ -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 });