Fix lint errors
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) {
|
||||
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;
|
||||
|
||||
await fs.mkdir(coverageDir, { recursive: true });
|
||||
|
||||
const titleParts =
|
||||
typeof testInfo.titlePath === 'function'
|
||||
? testInfo.titlePath()
|
||||
: Array.isArray((testInfo as any).titlePath)
|
||||
? (testInfo as any).titlePath
|
||||
: [testInfo.title];
|
||||
typeof testInfo.titlePath === 'function' ? testInfo.titlePath() : [testInfo.title];
|
||||
const safeTitle = titleParts
|
||||
.filter(Boolean)
|
||||
.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({
|
||||
page: async ({ page }, use, testInfo) => {
|
||||
await use(page);
|
||||
page: async ({ page }, applyPageFixture, testInfo) => {
|
||||
await applyPageFixture(page);
|
||||
await writeCoverageFile(page, testInfo);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, Page } from './fixtures';
|
||||
import { expect, test } from './fixtures';
|
||||
|
||||
import {
|
||||
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',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { defineConfig } from '@playwright/test';
|
||||
import { defineCoverageReporterConfig } from '@bgotink/playwright-coverage';
|
||||
import path from 'path';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { test } = require('@playwright/test');
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
test.afterEach(async ({ page }) => {
|
||||
try {
|
||||
// evaluate coverage from the page
|
||||
const coverage = await page.evaluate(() => (globalThis.__coverage__ || {}));
|
||||
|
||||
@@ -17,7 +17,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { useDatabase } from '../context/DBProvider';
|
||||
import { useProducts } from '../hooks/dataHooks';
|
||||
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 type { BackdropProps } from '@mui/material/Backdrop';
|
||||
import type { FormHelperTextProps } from '@mui/material/FormHelperText';
|
||||
@@ -48,7 +48,6 @@ export const AddProductDialog = ({
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
const [lookupStatus, setLookupStatus] = useState<'idle' | 'loading' | 'found' | 'notfound' | 'offline'>('idle');
|
||||
const [externalProduct, setExternalProduct] = useState<ExternalProductInfo | null>(null);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setName('');
|
||||
@@ -57,7 +56,6 @@ export const AddProductDialog = ({
|
||||
setBarcodeError('');
|
||||
setNameError('');
|
||||
setLookupStatus('idle');
|
||||
setExternalProduct(null);
|
||||
setScannerOpen(false);
|
||||
}, []);
|
||||
|
||||
@@ -73,19 +71,16 @@ export const AddProductDialog = ({
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, categoryOptions, initialBarcode]);
|
||||
}, [category, categoryOptions, initialBarcode, lookupBarcode, open, resetForm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !barcode) return;
|
||||
void lookupBarcode(barcode);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, barcode]);
|
||||
}, [barcode, lookupBarcode, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!barcode) {
|
||||
setLookupStatus('idle');
|
||||
setExternalProduct(null);
|
||||
}
|
||||
setBarcodeError('');
|
||||
}, [barcode]);
|
||||
@@ -185,26 +180,23 @@ export const AddProductDialog = ({
|
||||
[db.pickItems, db.pickLists, db.categories],
|
||||
);
|
||||
|
||||
async function lookupBarcode(code: string) {
|
||||
const lookupBarcode = useCallback(async (code: string) => {
|
||||
if (!code) return;
|
||||
if (typeof navigator !== 'undefined' && 'onLine' in navigator && navigator.onLine === false) {
|
||||
setLookupStatus('offline');
|
||||
setExternalProduct(null);
|
||||
return;
|
||||
}
|
||||
setLookupStatus('loading');
|
||||
const result = await fetchProductFromOFF(code);
|
||||
if (result) {
|
||||
setExternalProduct(result);
|
||||
setLookupStatus('found');
|
||||
if (result.name) {
|
||||
setName(result.name || '');
|
||||
}
|
||||
} else {
|
||||
setExternalProduct(null);
|
||||
setLookupStatus('notfound');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setNameError('');
|
||||
@@ -253,16 +245,17 @@ export const AddProductDialog = ({
|
||||
onFeedback?.({ text: 'Product added.', severity: 'success' });
|
||||
resetForm();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'DuplicateNameError') {
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'DuplicateNameError') {
|
||||
setNameError(err.message || 'A product with this name already exists.');
|
||||
return;
|
||||
}
|
||||
if (err?.name === 'DuplicateBarcodeError') {
|
||||
if (err instanceof Error && err.name === 'DuplicateBarcodeError') {
|
||||
setBarcodeError(err.message || 'This barcode is already assigned to another product.');
|
||||
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 {
|
||||
Alert,
|
||||
AlertColor,
|
||||
Button,
|
||||
IconButton,
|
||||
List,
|
||||
@@ -70,7 +69,8 @@ export const EditableEntityList = ({
|
||||
setNewName('');
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
} 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) {
|
||||
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
|
||||
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 { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// src/db/migrations.ts
|
||||
import { StockFillDB } from './index';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { PickList } from '../models/PickList';
|
||||
import { StockFillDB } from './index';
|
||||
|
||||
export const applyMigrations = async (db: StockFillDB) => {
|
||||
// Ensure DB is open and ready
|
||||
@@ -58,9 +59,10 @@ export const applyMigrations = async (db: StockFillDB) => {
|
||||
const pickLists = await db.pickLists.toArray();
|
||||
await Promise.all(
|
||||
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 (updatedById.has(entry)) return entry;
|
||||
// If entry is a name, return its id (if exists)
|
||||
@@ -71,7 +73,7 @@ export const applyMigrations = async (db: StockFillDB) => {
|
||||
});
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// src/screens/ActivePickListScreen.additional.test.tsx
|
||||
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 { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import ActivePickListScreen from './ActivePickListScreen';
|
||||
import { PickItem } from '../models/PickItem';
|
||||
import { Product } from '../models/Product';
|
||||
import { Category } from '../models/Category';
|
||||
|
||||
const addMock = vi.fn();
|
||||
const updateMock = vi.fn();
|
||||
@@ -15,7 +16,7 @@ const productsMock = vi.fn<() => Product[]>();
|
||||
const pickListMock = vi.fn();
|
||||
|
||||
// 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', () => ({
|
||||
usePickItems: () => pickItemsMock(),
|
||||
@@ -35,7 +36,7 @@ vi.mock('../context/DBProvider', () => ({
|
||||
return items.find((it) => it.id === id);
|
||||
}),
|
||||
delete: deleteMock,
|
||||
where: (_f: string) => ({
|
||||
where: () => ({
|
||||
equals: (val: any) => ({
|
||||
toArray: async () => {
|
||||
const items: PickItem[] = pickItemsMock() ?? [];
|
||||
@@ -96,12 +97,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
pickItemsMock.mockReturnValue([
|
||||
{
|
||||
|
||||
@@ -29,7 +29,7 @@ vi.mock('../context/DBProvider', () => ({
|
||||
update: updateMock,
|
||||
get: vi.fn(async (id: string) => (pickItemsMock() || []).find((it) => it.id === id)),
|
||||
delete: deleteMock,
|
||||
where: (_f: string) => ({
|
||||
where: () => ({
|
||||
equals: (val: any) => ({
|
||||
toArray: async () => (pickItemsMock() || []).filter((it) => it.pick_list_id === val),
|
||||
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'.
|
||||
*/
|
||||
|
||||
|
||||
// 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
|
||||
const getRadio = (testId: string) => {
|
||||
// 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.type(combobox, 'cola');
|
||||
|
||||
const listbox = await screen.findByRole('listbox');
|
||||
|
||||
expect(await screen.findByRole('option', { name: /cola \(drinks\)/i })).toBeVisible();
|
||||
expect(screen.queryByRole('option', { name: /chips \(snacks\)/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -489,7 +489,7 @@ const ActivePickListScreen = () => {
|
||||
SelectProps={{ native: true }}
|
||||
label="Filter by category"
|
||||
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 } }}
|
||||
>
|
||||
<option value="all">All categories</option>
|
||||
|
||||
@@ -44,7 +44,7 @@ const ManageAreasScreen = () => {
|
||||
entities={areas.map((area) => ({ id: area.id, name: area.name }))}
|
||||
onAdd={addArea}
|
||||
onUpdate={saveArea}
|
||||
onDelete={(areaId, areaName) => deleteArea(areaId)}
|
||||
onDelete={(areaId) => deleteArea(areaId)}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -36,7 +36,7 @@ vi.mock('../context/DBProvider', () => ({
|
||||
categories: { add: categoryAddMock, update: categoryUpdateMock, delete: categoryDeleteMock },
|
||||
products: {
|
||||
where: () => ({
|
||||
equals: (_: string) => ({
|
||||
equals: () => ({
|
||||
count: async () => 0,
|
||||
modify: async (changes: any) => productModifyMock(changes),
|
||||
}),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { ActionOutcome, EditableEntityList } from '../components/EditableEntityList';
|
||||
import { useDatabase } from '../context/DBProvider';
|
||||
import { useCategories, useProducts } from '../hooks/dataHooks';
|
||||
import type { PickList } from '../models/PickList';
|
||||
|
||||
const ManageCategoriesScreen = () => {
|
||||
const db = useDatabase();
|
||||
@@ -55,10 +56,11 @@ const ManageCategoriesScreen = () => {
|
||||
const pickLists = await db.pickLists.toArray();
|
||||
await Promise.all(
|
||||
pickLists.map(async (pickList) => {
|
||||
if (!Array.isArray((pickList as any).categories)) return;
|
||||
const needsUpdate = (pickList as any).categories.includes(category.name);
|
||||
const pickListCategories = (pickList as PickList).categories;
|
||||
if (!Array.isArray(pickListCategories)) return;
|
||||
const needsUpdate = pickListCategories.includes(category.name);
|
||||
if (!needsUpdate) return;
|
||||
const updatedCategories = (pickList as any).categories.map((existing: string) =>
|
||||
const updatedCategories = pickListCategories.map((existing: string) =>
|
||||
existing === category.name ? trimmed : existing,
|
||||
);
|
||||
await db.pickLists.update(pickList.id, { categories: updatedCategories });
|
||||
|
||||
@@ -83,8 +83,8 @@ describe('ManageProductsScreen update product category creation branch', () => {
|
||||
productsDb.get.mockResolvedValue(existingProduct);
|
||||
|
||||
// products.where('barcode').equals(value).first() must exist for uniqueness check
|
||||
productsDb.where.mockImplementation((field?: string) => ({
|
||||
equals: (value?: string) => ({
|
||||
productsDb.where.mockImplementation(() => ({
|
||||
equals: () => ({
|
||||
first: async () => undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -483,7 +483,7 @@ describe('ManageProductsScreen auto-adding products to pick lists', () => {
|
||||
// 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.where.mockImplementation(() => ({
|
||||
equals: (value: string) => ({
|
||||
equals: () => ({
|
||||
first: async () => ({ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -112,7 +112,6 @@ const ManageProductsScreen = () => {
|
||||
setPendingBarcode(state.newBarcode);
|
||||
setAddProductDialogOpen(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.state]);
|
||||
|
||||
const updateProduct = async (
|
||||
@@ -172,14 +171,15 @@ const ManageProductsScreen = () => {
|
||||
);
|
||||
|
||||
setFeedback({ text: 'Product updated.', severity: 'success' });
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import JSZip from 'jszip';
|
||||
import { importFiles } from './importExportService';
|
||||
import { createMockDb } from '../testUtils/mockDb';
|
||||
|
||||
@@ -3,13 +3,8 @@ import Papa from 'papaparse';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { StockFillDB } from '../db';
|
||||
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 { normalizeName, inferTypeFromName } from '../utils/stringUtils';
|
||||
import { coerceBoolean, coerceNumber } from '../utils/convUtils';
|
||||
import { triggerDownload } from '../platform/web';
|
||||
export { normalizeName, inferTypeFromName } from '../utils/stringUtils';
|
||||
export { coerceBoolean, coerceNumber } from '../utils/convUtils';
|
||||
@@ -56,7 +51,6 @@ const readFiles = async (files: File[]) => {
|
||||
if (file.name.toLowerCase().endsWith('.zip')) {
|
||||
const zip = await JSZip.loadAsync(file);
|
||||
const entries = Object.values(zip.files).filter((entry) => !entry.dir);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
for (const entry of entries) {
|
||||
const content = await entry.async('string');
|
||||
parsed.push({ name: entry.name, content });
|
||||
@@ -244,25 +238,25 @@ export const importFiles = async (
|
||||
|
||||
const parsedRows: Partial<Record<DataType, Record<string, string>[]>> = {};
|
||||
|
||||
let productRows: Record<string, string>[] = [];
|
||||
const productRows: Record<string, string>[] = [];
|
||||
|
||||
for (const file of parsedFiles) {
|
||||
const rows = await parseCsv(file.content);
|
||||
addDetail(`Parsed ${rows.length} rows from ${file.name}`);
|
||||
if (!rows || rows.length === 0) continue;
|
||||
for (const file of parsedFiles) {
|
||||
const rows = await parseCsv(file.content);
|
||||
addDetail(`Parsed ${rows.length} rows from ${file.name}`);
|
||||
if (!rows || rows.length === 0) continue;
|
||||
|
||||
// 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 isProductCentric =
|
||||
headerKeys.includes('category') && (headerKeys.includes('name') || headerKeys.includes('product_name'));
|
||||
// Look at headers of first row to decide if it's product-centric
|
||||
const headerKeys = Object.keys(rows[0]).map((header) => header.trim().toLowerCase());
|
||||
const isProductCentric =
|
||||
headerKeys.includes('category') && (headerKeys.includes('name') || headerKeys.includes('product_name'));
|
||||
|
||||
if (isProductCentric) {
|
||||
// Collect product rows for product-centric import
|
||||
productRows.push(...rows);
|
||||
// record that we detected products (for logging later)
|
||||
if (!selectedTypes.includes('products')) selectedTypes.push('products');
|
||||
} else {
|
||||
// Fallback to filename-based inference for older templates
|
||||
if (isProductCentric) {
|
||||
// Collect product rows for product-centric import
|
||||
productRows.push(...rows);
|
||||
// record that we detected products (for logging later)
|
||||
if (!selectedTypes.includes('products')) selectedTypes.push('products');
|
||||
} else {
|
||||
// Fallback to filename-based inference for older templates
|
||||
const type = inferTypeFromName(file.name) as DataType | undefined;
|
||||
if (!type) {
|
||||
addDetail(`Skipped ${file.name}: not product-centric and could not infer data type`);
|
||||
@@ -306,82 +300,77 @@ for (const file of parsedFiles) {
|
||||
});
|
||||
|
||||
try {
|
||||
await db.transaction(
|
||||
'rw',
|
||||
[db.categories, db.products],
|
||||
async () => {
|
||||
if (productRows.length > 0) {
|
||||
// 1) Make a unique list of category names from CSV (normalized)
|
||||
const uniqueCategoryNames = new Map<string, string>(); // normalized -> raw
|
||||
for (const row of productRows) {
|
||||
const rawCat = (row.category ?? '').trim();
|
||||
if (!rawCat) continue;
|
||||
const norm = normalizeName(rawCat);
|
||||
if (norm && !uniqueCategoryNames.has(norm)) uniqueCategoryNames.set(norm, rawCat);
|
||||
}
|
||||
await db.transaction('rw', [db.categories, db.products], async () => {
|
||||
if (productRows.length > 0) {
|
||||
// 1) Make a unique list of category names from CSV (normalized)
|
||||
const uniqueCategoryNames = new Map<string, string>(); // normalized -> raw
|
||||
for (const row of productRows) {
|
||||
const rawCat = (row.category ?? '').trim();
|
||||
if (!rawCat) continue;
|
||||
const norm = normalizeName(rawCat);
|
||||
if (norm && !uniqueCategoryNames.has(norm)) uniqueCategoryNames.set(norm, rawCat);
|
||||
}
|
||||
|
||||
// 2) Create missing categories in DB
|
||||
for (const [norm, raw] of uniqueCategoryNames) {
|
||||
if (!categoryNameToId.has(norm)) {
|
||||
const newId = uuidv4();
|
||||
categoryNameToId.set(norm, newId);
|
||||
await db.categories.add({
|
||||
id: newId,
|
||||
name: raw,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
addDetail(`Created category "${raw}"`);
|
||||
}
|
||||
}
|
||||
// 2) Create missing categories in DB
|
||||
for (const [norm, raw] of uniqueCategoryNames) {
|
||||
if (!categoryNameToId.has(norm)) {
|
||||
const newId = uuidv4();
|
||||
categoryNameToId.set(norm, newId);
|
||||
await db.categories.add({
|
||||
id: newId,
|
||||
name: raw,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
addDetail(`Created category "${raw}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Create products, linking to categories
|
||||
for (const row of productRows) {
|
||||
const nameRaw = (row.product_name ?? row.name ?? '').trim();
|
||||
const name = normalizeName(nameRaw);
|
||||
if (!name) {
|
||||
addDetail('Skipped product with empty name');
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (productNameToId.has(name)) {
|
||||
addDetail(`Product "${nameRaw}" exists, skipping`);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
// 3) Create products, linking to categories
|
||||
for (const row of productRows) {
|
||||
const nameRaw = (row.product_name ?? row.name ?? '').trim();
|
||||
const name = normalizeName(nameRaw);
|
||||
if (!name) {
|
||||
addDetail('Skipped product with empty name');
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (productNameToId.has(name)) {
|
||||
addDetail(`Product "${nameRaw}" exists, skipping`);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const catRaw = (row.category ?? '').trim();
|
||||
const categoryId = catRaw ? categoryNameToId.get(normalizeName(catRaw)) : undefined;
|
||||
const catRaw = (row.category ?? '').trim();
|
||||
const categoryId = catRaw ? categoryNameToId.get(normalizeName(catRaw)) : undefined;
|
||||
|
||||
const barcode = row.barcode?.trim();
|
||||
if (barcode && barcodeToProductId.has(barcode)) {
|
||||
addDetail(`Barcode ${barcode} already exists, clearing for product "${nameRaw}"`);
|
||||
}
|
||||
const barcode = row.barcode?.trim();
|
||||
if (barcode && barcodeToProductId.has(barcode)) {
|
||||
addDetail(`Barcode ${barcode} already exists, clearing for product "${nameRaw}"`);
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
const product: Product = {
|
||||
id,
|
||||
name: nameRaw,
|
||||
category: categoryId ?? '',
|
||||
unit_type: DEFAULT_UNIT_TYPE,
|
||||
bulk_name: DEFAULT_BULK_NAME,
|
||||
barcode: barcode && !barcodeToProductId.has(barcode) ? barcode : undefined,
|
||||
archived: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
await db.products.add(product);
|
||||
productNameToId.set(name, id);
|
||||
if (product.barcode) {
|
||||
barcodeToProductId.set(product.barcode, id);
|
||||
}
|
||||
inserted += 1;
|
||||
addDetail(`Created product "${product.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
);
|
||||
const id = uuidv4();
|
||||
const product: Product = {
|
||||
id,
|
||||
name: nameRaw,
|
||||
category: categoryId ?? '',
|
||||
unit_type: DEFAULT_UNIT_TYPE,
|
||||
bulk_name: DEFAULT_BULK_NAME,
|
||||
barcode: barcode && !barcodeToProductId.has(barcode) ? barcode : undefined,
|
||||
archived: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
await db.products.add(product);
|
||||
productNameToId.set(name, id);
|
||||
if (product.barcode) {
|
||||
barcodeToProductId.set(product.barcode, id);
|
||||
}
|
||||
inserted += 1;
|
||||
addDetail(`Created product "${product.name}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
errors += 1;
|
||||
addDetail(`Import failed: ${(error as Error).message}`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export function makeNamedError(name: string, message?: string) {
|
||||
const error = new Error(message ?? name);
|
||||
(error as any).name = name;
|
||||
error.name = name;
|
||||
return error;
|
||||
}
|
||||
|
||||
+15
-11
@@ -4,6 +4,7 @@ import type { Category } from '../models/Category';
|
||||
import type { Area } from '../models/Area';
|
||||
import type { PickList } from '../models/PickList';
|
||||
import type { PickItem } from '../models/PickItem';
|
||||
import type { ImportExportLog } from '../models/ImportExportLog';
|
||||
|
||||
export class MockTable<T extends { id: string }> {
|
||||
items: T[];
|
||||
@@ -39,19 +40,19 @@ export class MockTable<T extends { id: string }> {
|
||||
this.items = this.items.filter((i) => i.id !== id);
|
||||
}
|
||||
|
||||
where(field: string) {
|
||||
where<K extends keyof T>(field: K) {
|
||||
return {
|
||||
equals: (val: any) => ({
|
||||
first: async () => this.items.find((it: any) => it[field] === val),
|
||||
count: async () => this.items.filter((it: any) => it[field] === val).length,
|
||||
filter: (pred: (it: any) => boolean) => ({
|
||||
first: async () => this.items.find((it: any) => it[field] === val && pred(it)),
|
||||
equals: (val: T[K]) => ({
|
||||
first: async () => this.items.find((it) => it[field] === val),
|
||||
count: async () => this.items.filter((it) => it[field] === val).length,
|
||||
filter: (pred: (it: T) => boolean) => ({
|
||||
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);
|
||||
return {
|
||||
delete: async () => {
|
||||
@@ -68,7 +69,7 @@ export const createMockDb = (data?: {
|
||||
areas?: Area[];
|
||||
pickLists?: PickList[];
|
||||
pickItems?: PickItem[];
|
||||
importExportLogs?: any[];
|
||||
importExportLogs?: ImportExportLog[];
|
||||
}) => {
|
||||
return {
|
||||
products: new MockTable<Product>(data?.products ?? []),
|
||||
@@ -76,9 +77,12 @@ export const createMockDb = (data?: {
|
||||
areas: new MockTable<Area>(data?.areas ?? []),
|
||||
pickLists: new MockTable<PickList>(data?.pickLists ?? []),
|
||||
pickItems: new MockTable<PickItem>(data?.pickItems ?? []),
|
||||
importExportLogs: new MockTable<any>(data?.importExportLogs ?? []),
|
||||
transaction: async (_mode: string, ...args: any[]) => {
|
||||
const cb = args[args.length - 1];
|
||||
importExportLogs: new MockTable<ImportExportLog>(data?.importExportLogs ?? []),
|
||||
transaction: async (
|
||||
_mode: string,
|
||||
...args: Array<MockTable<unknown> | (() => unknown)>
|
||||
) => {
|
||||
const cb = args.at(-1);
|
||||
if (typeof cb === 'function') return cb();
|
||||
return undefined;
|
||||
},
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
export const stubDownloads = (vi: any) => {
|
||||
const anchor = { href: '', download: '', click: vi.fn() } as any;
|
||||
type VitestMocker = typeof import('vitest')['vi'];
|
||||
|
||||
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 revokeObjectURL = vi.fn();
|
||||
vi.stubGlobal('document', { createElement: () => anchor });
|
||||
|
||||
Reference in New Issue
Block a user