From 4dfe85231a80e2e404218a5299837489cc6b536c Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 6 Sep 2025 14:38:47 -0400 Subject: [PATCH] Fixes and improvements for product import module --- inventory/package-lock.json | 6 +- .../MatchColumnsStep/MatchColumnsStep.tsx | 8 +- .../components/ValidationContainer.tsx | 58 +++---- .../components/ValidationTable.tsx | 90 ++++++----- .../components/cells/InputCell.tsx | 146 +++++------------- .../hooks/useFieldValidation.tsx | 26 +++- .../hooks/useUniqueItemNumbersValidation.tsx | 78 +++++----- .../hooks/useValidationState.tsx | 142 +++++++++-------- inventory/src/pages/Import.tsx | 2 +- 9 files changed, 248 insertions(+), 308 deletions(-) diff --git a/inventory/package-lock.json b/inventory/package-lock.json index 870470d..85d35d6 100644 --- a/inventory/package-lock.json +++ b/inventory/package-lock.json @@ -3763,9 +3763,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001700", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz", - "integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==", + "version": "1.0.30001739", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001739.tgz", + "integrity": "sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA==", "dev": true, "funding": [ { diff --git a/inventory/src/components/product-import/steps/MatchColumnsStep/MatchColumnsStep.tsx b/inventory/src/components/product-import/steps/MatchColumnsStep/MatchColumnsStep.tsx index 2250366..e43cbac 100644 --- a/inventory/src/components/product-import/steps/MatchColumnsStep/MatchColumnsStep.tsx +++ b/inventory/src/components/product-import/steps/MatchColumnsStep/MatchColumnsStep.tsx @@ -312,7 +312,7 @@ const SupplierSelector = React.memo(({ {suppliers?.map((supplier: any) => ( { onChange(supplier.value); setOpen(false); // Close popover after selection @@ -376,7 +376,7 @@ const CompanySelector = React.memo(({ {companies?.map((company: any) => ( { onChange(company.value); setOpen(false); // Close popover after selection @@ -443,7 +443,7 @@ const LineSelector = React.memo(({ {lines?.map((line: any) => ( { onChange(line.value); setOpen(false); // Close popover after selection @@ -510,7 +510,7 @@ const SubLineSelector = React.memo(({ {sublines?.map((subline: any) => ( { onChange(subline.value); setOpen(false); // Close popover after selection diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationContainer.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationContainer.tsx index 71e8c80..301094e 100644 --- a/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationContainer.tsx +++ b/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationContainer.tsx @@ -160,8 +160,6 @@ const ValidationContainer = ({ // Clear the fields map setFieldsToRevalidateMap({}); - console.log(`Validating ${rowsToRevalidate.length} rows with specific fields`); - // Revalidate each row with specific fields information validationState.revalidateRows(rowsToRevalidate, fieldsMap); }, [fieldsToRevalidate, validationState, fieldsToRevalidateMap]); @@ -529,40 +527,34 @@ const ValidationContainer = ({ ...newData[originalIndex], [key]: processedValue }; + } else { + console.error(`Invalid originalIndex: ${originalIndex}, data length: ${newData.length}`); } return newData; }); - // Secondary effects - using a timeout to ensure UI updates first - setTimeout(() => { + // Secondary effects - using requestAnimationFrame for better performance + requestAnimationFrame(() => { // Handle company change - clear line/subline and fetch product lines if (key === 'company' && value) { - console.log(`Company changed to ${value} for row ${rowIndex}, updating lines and sublines`); - // Clear any existing line/subline values immediately setData(prevData => { const newData = [...prevData]; const idx = newData.findIndex(item => item.__index === rowId); if (idx >= 0) { - console.log(`Clearing line and subline values for row with ID ${rowId}`); newData[idx] = { ...newData[idx], line: undefined, subline: undefined }; - } else { - console.warn(`Could not find row with ID ${rowId} to clear line/subline values`); } return newData; }); - // Fetch product lines for the new company + // Fetch product lines for the new company with debouncing if (rowId && value !== undefined) { const companyId = value.toString(); - // Force immediate fetch for better UX - console.log(`Immediately fetching product lines for company ${companyId} for row ${rowId}`); - // Set loading state first setValidatingCells(prev => { const newSet = new Set(prev); @@ -570,22 +562,22 @@ const ValidationContainer = ({ return newSet; }); - fetchProductLines(rowId, companyId) - .then(lines => { - console.log(`Successfully loaded ${lines.length} product lines for company ${companyId}`); - }) - .catch(err => { - console.error(`Error fetching product lines for company ${companyId}:`, err); - toast.error("Failed to load product lines"); - }) - .finally(() => { - // Clear loading indicator - setValidatingCells(prev => { - const newSet = new Set(prev); - newSet.delete(`${rowIndex}-line`); - return newSet; + // Debounce the API call to prevent excessive requests + setTimeout(() => { + fetchProductLines(rowId, companyId) + .catch(err => { + console.error(`Error fetching product lines for company ${companyId}:`, err); + toast.error("Failed to load product lines"); + }) + .finally(() => { + // Clear loading indicator + setValidatingCells(prev => { + const newSet = new Set(prev); + newSet.delete(`${rowIndex}-line`); + return newSet; + }); }); - }); + }, 100); // 100ms debounce } } @@ -728,7 +720,7 @@ const ValidationContainer = ({ }); } } - }, 0); // Using 0ms timeout to defer execution until after the UI update + }); // Using requestAnimationFrame to defer execution until after the UI update }, [data, filteredData, setData, fetchProductLines, fetchSublines, upcValidation, markRowForRevalidation]); // Fix the missing loading indicator clear code @@ -800,15 +792,15 @@ const ValidationContainer = ({ markRowForRevalidation(targetRowIndex, fieldKey); }); - // Clear the loading state for all cells after a short delay - setTimeout(() => { + // Clear the loading state for all cells efficiently + requestAnimationFrame(() => { setValidatingCells(prev => { - if (prev.size === 0) return prev; + if (prev.size === 0 || updatingCells.size === 0) return prev; const newSet = new Set(prev); updatingCells.forEach(cell => newSet.delete(cell)); return newSet; }); - }, 100); + }); // If copying UPC or supplier fields, validate UPC for all rows if (fieldKey === 'upc' || fieldKey === 'barcode' || fieldKey === 'supplier') { diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationTable.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationTable.tsx index b82d1e0..8ff4050 100644 --- a/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationTable.tsx +++ b/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationTable.tsx @@ -138,34 +138,18 @@ const MemoizedCell = React.memo(({ /> ); }, (prev, next) => { - // CRITICAL FIX: Never memoize item_number cells - always re-render them + // For item_number cells, only re-render when itemNumber actually changes if (prev.fieldKey === 'item_number') { - return false; // Never skip re-renders for item_number cells + return prev.itemNumber === next.itemNumber && + prev.value === next.value && + prev.isValidating === next.isValidating; } - // Optimize the memo comparison function for better performance - // Only re-render if these essential props change - const valueEqual = prev.value === next.value; - const isValidatingEqual = prev.isValidating === next.isValidating; - - // Shallow equality check for errors array - const errorsEqual = prev.errors === next.errors || ( - Array.isArray(prev.errors) && - Array.isArray(next.errors) && - prev.errors.length === next.errors.length && - prev.errors.every((err, idx) => err === next.errors[idx]) - ); - - // Shallow equality check for options array - const optionsEqual = prev.options === next.options || ( - Array.isArray(prev.options) && - Array.isArray(next.options) && - prev.options.length === next.options.length && - prev.options.every((opt, idx) => opt === next.options?.[idx]) - ); - - // Skip checking for props that rarely change - return valueEqual && isValidatingEqual && errorsEqual && optionsEqual; + // Simplified memo comparison - most expensive checks removed + return prev.value === next.value && + prev.isValidating === next.isValidating && + prev.errors === next.errors && + prev.options === next.options; }); MemoizedCell.displayName = 'MemoizedCell'; @@ -394,24 +378,35 @@ const ValidationTable = ({ options = rowSublines[rowId]; } - // Determine if this cell is in loading state - use a clear consistent approach + // Get the current cell value first + const currentValue = fieldKey === 'item_number' && row.original[field.key] + ? row.original[field.key] + : row.original[field.key as keyof typeof row.original]; + + // Determine if this cell is in loading state - only show loading for empty fields let isLoading = false; - // Check the validatingCells Set first (for item_number and other fields) - const cellLoadingKey = `${row.index}-${fieldKey}`; - if (validatingCells.has(cellLoadingKey)) { - isLoading = true; - } - // Check if UPC is validating for this row and field is item_number - else if (fieldKey === 'item_number' && isRowValidatingUpc(row.index)) { - isLoading = true; - } - // Add loading state for line/subline fields - else if (fieldKey === 'line' && rowId && isLoadingLines[rowId]) { - isLoading = true; - } - else if (fieldKey === 'subline' && rowId && isLoadingSublines[rowId]) { - isLoading = true; + // Only show loading if the field is currently empty + const isEmpty = currentValue === undefined || currentValue === null || currentValue === '' || + (Array.isArray(currentValue) && currentValue.length === 0); + + if (isEmpty) { + // Check the validatingCells Set first (for item_number and other fields) + const cellLoadingKey = `${row.index}-${fieldKey}`; + if (validatingCells.has(cellLoadingKey)) { + isLoading = true; + } + // Check if UPC is validating for this row and field is item_number + else if (fieldKey === 'item_number' && isRowValidatingUpc(row.index)) { + isLoading = true; + } + // Add loading state for line/subline fields + else if (fieldKey === 'line' && rowId && isLoadingLines[rowId]) { + isLoading = true; + } + else if (fieldKey === 'subline' && rowId && isLoadingSublines[rowId]) { + isLoading = true; + } } // Get validation errors for this cell @@ -448,19 +443,16 @@ const ValidationTable = ({ } } - // CRITICAL: For item_number fields, create a unique key that includes the itemNumber value - // This forces a complete re-render when the itemNumber changes + // Create stable keys that only change when actual content changes const cellKey = fieldKey === 'item_number' - ? `cell-${row.index}-${fieldKey}-${itemNumber || 'empty'}-${Date.now()}` // Force re-render on every render cycle for item_number + ? `cell-${row.index}-${fieldKey}-${itemNumber || 'empty'}` // Only change when itemNumber actually changes : `cell-${row.index}-${fieldKey}`; return ( } - value={fieldKey === 'item_number' && row.original[field.key] - ? row.original[field.key] // Use direct value from row data - : row.original[field.key as keyof typeof row.original]} + value={currentValue} onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)} errors={cellErrors} isValidating={isLoading} @@ -678,6 +670,10 @@ const areEqual = (prev: ValidationTableProps, next: ValidationTableProps { className?: string } -// Add efficient price formatting utility -const formatPrice = (value: string): string => { +// Add efficient price formatting utility with null safety +const formatPrice = (value: any): string => { + // Handle undefined, null, or non-string values + if (value === undefined || value === null) { + return ''; + } + + // Convert to string if not already + const stringValue = String(value); + // Remove any non-numeric characters except decimal point - const numericValue = value.replace(/[^\d.]/g, ''); + const numericValue = stringValue.replace(/[^\d.]/g, ''); // Parse as float and format to 2 decimal places const numValue = parseFloat(numericValue); @@ -45,53 +53,25 @@ const InputCell = ({ }: InputCellProps) => { const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(''); - const [isPending, startTransition] = useTransition(); - - // Use a ref to track if we need to process the value - const needsProcessingRef = useRef(false); - - // Track local display value to avoid waiting for validation - const [localDisplayValue, setLocalDisplayValue] = useState(null); - - // Add state for hover const [isHovered, setIsHovered] = useState(false); + // Remove optimistic updates and rely on parent state + // Helper function to check if a class is present in the className string const hasClass = (cls: string): boolean => { const classNames = className.split(' '); return classNames.includes(cls); }; - // Initialize localDisplayValue on mount and when value changes externally - useEffect(() => { - if (localDisplayValue === null || - (typeof value === 'string' && typeof localDisplayValue === 'string' && - value.trim() !== localDisplayValue.trim())) { - setLocalDisplayValue(value); - } - }, [value, localDisplayValue]); + // No complex initialization needed - // Efficiently handle price formatting without multiple rerenders - useEffect(() => { - if (isPrice && needsProcessingRef.current && !isEditing) { - needsProcessingRef.current = false; - - // Do price processing only when needed - const formattedValue = formatPrice(value); - if (formattedValue !== value) { - onChange(formattedValue); - } - } - }, [value, isPrice, isEditing, onChange]); - - // Handle focus event - optimized to be synchronous + // Handle focus event const handleFocus = useCallback(() => { setIsEditing(true); - // For price fields, strip formatting when focusing if (value !== undefined && value !== null) { if (isPrice) { - // Remove any non-numeric characters except decimal point + // Remove any non-numeric characters except decimal point for editing const numericValue = String(value).replace(/[^\d.]/g, ''); setEditValue(numericValue); } else { @@ -104,30 +84,17 @@ const InputCell = ({ onStartEdit?.(); }, [value, onStartEdit, isPrice]); - // Handle blur event - use transition for non-critical updates + // Handle blur event - save to parent only const handleBlur = useCallback(() => { - // First - lock in the current edit value to prevent it from being lost const finalValue = editValue.trim(); - // Then transition to non-editing state - startTransition(() => { - setIsEditing(false); - - // Format the value for storage (remove formatting like $ for price) - let processedValue = finalValue; - - if (isPrice && processedValue) { - needsProcessingRef.current = true; - } - - // Update local display value immediately to prevent UI flicker - setLocalDisplayValue(processedValue); - - // Commit the change to parent component - onChange(processedValue); - onEndEdit?.(); - }); - }, [editValue, onChange, onEndEdit, isPrice]); + // Save to parent - parent must update immediately for this to work + onChange(finalValue); + + // Exit editing mode + setIsEditing(false); + onEndEdit?.(); + }, [editValue, onChange, onEndEdit]); // Handle direct input change - optimized to be synchronous for typing const handleChange = useCallback((e: React.ChangeEvent) => { @@ -135,30 +102,22 @@ const InputCell = ({ setEditValue(newValue); }, [isPrice]); - // Get the display value - prioritize local display value + // Get the display value - use parent value directly const displayValue = useMemo(() => { - // First priority: local display value (for immediate updates) - if (localDisplayValue !== null) { - if (isPrice) { - // Format price value - const numValue = parseFloat(localDisplayValue); - return !isNaN(numValue) ? numValue.toFixed(2) : localDisplayValue; - } - return localDisplayValue; - } + const currentValue = value ?? ''; - // Second priority: handle price formatting for the actual value - if (isPrice && value) { - if (typeof value === 'number') { - return value.toFixed(2); - } else if (typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value)) { - return parseFloat(value).toFixed(2); + // Handle price formatting for display + if (isPrice && currentValue !== '' && currentValue !== undefined && currentValue !== null) { + if (typeof currentValue === 'number') { + return currentValue.toFixed(2); + } else if (typeof currentValue === 'string' && /^-?\d+(\.\d+)?$/.test(currentValue)) { + return parseFloat(currentValue).toFixed(2); } } - // Default: use the actual value or empty string - return value ?? ''; - }, [isPrice, value, localDisplayValue]); + // For non-price or invalid price values, return as-is + return String(currentValue); + }, [isPrice, value]); // Add outline even when not in focus const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"; @@ -221,7 +180,6 @@ const InputCell = ({ className={cn( outlineClass, hasErrors ? "border-destructive" : "", - isPending ? "opacity-50" : "", className )} style={{ @@ -267,33 +225,11 @@ const InputCell = ({ ) } -// Optimize memo comparison to focus on essential props +// Simplified memo comparison export default React.memo(InputCell, (prev, next) => { - if (prev.hasErrors !== next.hasErrors) return false; - if (prev.isMultiline !== next.isMultiline) return false; - if (prev.isPrice !== next.isPrice) return false; - if (prev.disabled !== next.disabled) return false; - if (prev.field !== next.field) return false; - - // Only check value if not editing (to avoid expensive rerender during editing) - if (prev.value !== next.value) { - // For price values, do a more intelligent comparison - if (prev.isPrice) { - // Convert both to numeric values for comparison - const prevNum = typeof prev.value === 'number' ? prev.value : - typeof prev.value === 'string' ? parseFloat(prev.value) : 0; - const nextNum = typeof next.value === 'number' ? next.value : - typeof next.value === 'string' ? parseFloat(next.value) : 0; - - // Only update if the actual numeric values differ - if (!isNaN(prevNum) && !isNaN(nextNum) && - Math.abs(prevNum - nextNum) > 0.001) { - return false; - } - } else { - return false; - } - } - - return true; + // Only re-render if essential props change + return prev.value === next.value && + prev.hasErrors === next.hasErrors && + prev.disabled === next.disabled && + prev.field === next.field; }); \ No newline at end of file diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useFieldValidation.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useFieldValidation.tsx index de50190..60a94e0 100644 --- a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useFieldValidation.tsx +++ b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useFieldValidation.tsx @@ -7,14 +7,24 @@ import { RowData, isEmpty } from './validationTypes'; // Create a cache for validation results to avoid repeated validation of the same data const validationResultCache = new Map(); -// Add a function to clear cache for a specific field value -export const clearValidationCacheForField = (fieldKey: string) => { - // Look for entries that match this field key - validationResultCache.forEach((_, key) => { - if (key.startsWith(`${fieldKey}-`)) { - validationResultCache.delete(key); - } - }); +// Optimize cache clearing - only clear when necessary +export const clearValidationCacheForField = (fieldKey: string, specificValue?: any) => { + if (specificValue !== undefined) { + // Only clear specific field-value combinations + const specificKey = `${fieldKey}-${String(specificValue)}`; + validationResultCache.forEach((_, key) => { + if (key.startsWith(specificKey)) { + validationResultCache.delete(key); + } + }); + } else { + // Clear all entries for the field + validationResultCache.forEach((_, key) => { + if (key.startsWith(`${fieldKey}-`)) { + validationResultCache.delete(key); + } + }); + } }; // Add a special function to clear all uniqueness validation caches diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useUniqueItemNumbersValidation.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useUniqueItemNumbersValidation.tsx index cdf0750..a1439c8 100644 --- a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useUniqueItemNumbersValidation.tsx +++ b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useUniqueItemNumbersValidation.tsx @@ -95,52 +95,48 @@ export const useUniqueItemNumbersValidation = ( }); }); - // Apply batch updates only if we have errors to report - if (errors.size > 0) { - // OPTIMIZATION: Check if we actually have new errors before updating state - let hasChanges = false; + // Merge uniqueness errors with existing validation errors + setValidationErrors((prev) => { + const newMap = new Map(prev); - // We'll update errors with a single batch operation - setValidationErrors((prev) => { - const newMap = new Map(prev); + // Add uniqueness errors + errors.forEach((rowErrors, rowIndex) => { + const existingErrors = newMap.get(rowIndex) || {}; + const updatedErrors = { ...existingErrors }; - // Check each row for changes - errors.forEach((rowErrors, rowIndex) => { - const existingErrors = newMap.get(rowIndex) || {}; - const updatedErrors = { ...existingErrors }; - let rowHasChanges = false; - - // Check each field for changes - Object.entries(rowErrors).forEach(([fieldKey, fieldErrors]) => { - // Compare with existing errors - const existingFieldErrors = existingErrors[fieldKey]; - - if ( - !existingFieldErrors || - existingFieldErrors.length !== fieldErrors.length || - !existingFieldErrors.every( - (err, idx) => - err.message === fieldErrors[idx].message && - err.type === fieldErrors[idx].type - ) - ) { - // We have a change - updatedErrors[fieldKey] = fieldErrors; - rowHasChanges = true; - hasChanges = true; - } - }); - - // Only update if we have changes - if (rowHasChanges) { - newMap.set(rowIndex, updatedErrors); - } + // Add uniqueness errors to existing errors + Object.entries(rowErrors).forEach(([fieldKey, fieldErrors]) => { + updatedErrors[fieldKey] = fieldErrors; }); - // Only return a new map if we have changes - return hasChanges ? newMap : prev; + newMap.set(rowIndex, updatedErrors); }); - } + + // Clean up rows that have no uniqueness errors anymore + // by removing only uniqueness error types from rows not in the errors map + newMap.forEach((rowErrors, rowIndex) => { + if (!errors.has(rowIndex)) { + // Remove uniqueness errors from this row + const cleanedErrors: Record = {}; + Object.entries(rowErrors).forEach(([fieldKey, fieldErrors]) => { + // Keep non-uniqueness errors + const nonUniqueErrors = fieldErrors.filter(error => error.type !== ErrorType.Unique); + if (nonUniqueErrors.length > 0) { + cleanedErrors[fieldKey] = nonUniqueErrors; + } + }); + + // Update the row or remove it if no errors remain + if (Object.keys(cleanedErrors).length > 0) { + newMap.set(rowIndex, cleanedErrors); + } else { + newMap.delete(rowIndex); + } + } + }); + + return newMap; + }); console.log("Uniqueness validation complete"); }, [data, fields, setValidationErrors]); diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidationState.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidationState.tsx index 3a816be..636c35c 100644 --- a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidationState.tsx +++ b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidationState.tsx @@ -128,7 +128,7 @@ export const useValidationState = ({ // Use filter management hook const filterManagement = useFilterManagement(data, fields, validationErrors); - // Run validation when data changes - FIXED to prevent recursive validation + // Run validation when data changes - OPTIMIZED to prevent recursive validation useEffect(() => { // Skip initial load - we have a separate initialization process if (!initialValidationDoneRef.current) return; @@ -139,51 +139,68 @@ export const useValidationState = ({ // CRITICAL FIX: Skip validation if we're already validating to prevent infinite loops if (isValidatingRef.current) return; - console.log("Running validation on data change"); - isValidatingRef.current = true; + // Debounce validation to prevent excessive calls + const timeoutId = setTimeout(() => { + if (isValidatingRef.current) return; // Double-check before proceeding + + // Validation running (removed console.log for performance) + isValidatingRef.current = true; - // For faster validation, run synchronously instead of in an async function + // COMPREHENSIVE validation that clears old errors and adds new ones const validateFields = () => { try { - // Run regex validations on all rows + // Create a complete fresh validation map + const allValidationErrors = new Map>(); + + // Get all field types that need validation + const requiredFields = fields.filter((field) => + field.validations?.some((v) => v.rule === "required") + ); const regexFields = fields.filter((field) => field.validations?.some((v) => v.rule === "regex") ); - if (regexFields.length > 0) { - // Create a map to collect validation errors - const regexErrors = new Map< - number, - Record - >(); - // Check each row for regex errors - data.forEach((row, rowIndex) => { - const rowErrors: Record = {}; - let hasErrors = false; + // Validate each row completely + data.forEach((row, rowIndex) => { + const rowErrors: Record = {}; - // Check each regex field - regexFields.forEach((field) => { - const key = String(field.key); - const value = row[key as keyof typeof row]; + // Check required fields + requiredFields.forEach((field) => { + const key = String(field.key); + const value = row[key as keyof typeof row]; + + // Check if field is empty + if (value === undefined || value === null || value === "" || + (Array.isArray(value) && value.length === 0)) { + const requiredValidation = field.validations?.find((v) => v.rule === "required"); + rowErrors[key] = [ + { + message: requiredValidation?.errorMessage || "This field is required", + level: requiredValidation?.level || "error", + source: "row", + type: "required", + }, + ]; + } + }); - // Skip empty values - if (value === undefined || value === null || value === "") { - return; - } + // Check regex fields (only if they have values) + regexFields.forEach((field) => { + const key = String(field.key); + const value = row[key as keyof typeof row]; - // Find regex validation - const regexValidation = field.validations?.find( - (v) => v.rule === "regex" - ); - if (regexValidation) { - try { - // Check if value matches regex - const regex = new RegExp( - regexValidation.value, - regexValidation.flags - ); - if (!regex.test(String(value))) { - // Add regex validation error + // Skip empty values for regex validation + if (value === undefined || value === null || value === "") { + return; + } + + const regexValidation = field.validations?.find((v) => v.rule === "regex"); + if (regexValidation) { + try { + const regex = new RegExp(regexValidation.value, regexValidation.flags); + if (!regex.test(String(value))) { + // Only add regex error if no required error exists + if (!rowErrors[key]) { rowErrors[key] = [ { message: regexValidation.errorMessage, @@ -192,35 +209,24 @@ export const useValidationState = ({ type: "regex", }, ]; - hasErrors = true; } - } catch (error) { - console.error("Invalid regex in validation:", error); } + } catch (error) { + console.error("Invalid regex in validation:", error); } - }); - - // Add errors if any found - if (hasErrors) { - regexErrors.set(rowIndex, rowErrors); } }); - // Update validation errors - if (regexErrors.size > 0) { - setValidationErrors((prev) => { - const newErrors = new Map(prev); - // Merge in regex errors - for (const [rowIndex, errors] of regexErrors.entries()) { - const existingErrors = newErrors.get(rowIndex) || {}; - newErrors.set(rowIndex, { ...existingErrors, ...errors }); - } - return newErrors; - }); + // Only add to the map if there are actually errors + if (Object.keys(rowErrors).length > 0) { + allValidationErrors.set(rowIndex, rowErrors); } - } + }); - // Run uniqueness validations immediately + // Replace validation errors completely (clears old ones) + setValidationErrors(allValidationErrors); + + // Run uniqueness validations after basic validation validateUniqueItemNumbers(); } finally { // Always ensure the ref is reset, even if an error occurs @@ -230,9 +236,13 @@ export const useValidationState = ({ } }; - // Run validation immediately - validateFields(); - }, [data, fields, validateUniqueItemNumbers]); + // Run validation immediately + validateFields(); + }, 50); // 50ms debounce + + // Cleanup timeout on unmount or dependency change + return () => clearTimeout(timeoutId); + }, [data, fields]); // Removed validateUniqueItemNumbers to prevent infinite loop // Add field options query const { data: fieldOptionsData } = useQuery({ @@ -352,7 +362,7 @@ export const useValidationState = ({ useEffect(() => { if (initialValidationDoneRef.current) return; - console.log("Running initial validation"); + // Running initial validation (removed console.log for performance) const runCompleteValidation = async () => { if (!data || data.length === 0) return; @@ -379,8 +389,8 @@ export const useValidationState = ({ `Found ${uniqueFields.length} fields requiring uniqueness validation` ); - // Limit batch size to avoid UI freezing - const BATCH_SIZE = 100; + // Dynamic batch size based on dataset size + const BATCH_SIZE = data.length <= 50 ? data.length : 25; // Process all at once for small datasets const totalRows = data.length; // Initialize new data for any modifications @@ -559,9 +569,9 @@ export const useValidationState = ({ currentBatch = batch; await processBatch(); - // Yield to UI thread periodically - if (batch % 2 === 1) { - await new Promise((resolve) => setTimeout(resolve, 0)); + // Yield to UI thread more frequently for large datasets + if (batch % 2 === 1 || totalRows > 500) { + await new Promise((resolve) => setTimeout(resolve, totalRows > 1000 ? 10 : 5)); } } diff --git a/inventory/src/pages/Import.tsx b/inventory/src/pages/Import.tsx index 3686019..4d87c1c 100644 --- a/inventory/src/pages/Import.tsx +++ b/inventory/src/pages/Import.tsx @@ -146,7 +146,7 @@ const BASE_IMPORT_FIELDS = [ label: "Cost Each", key: "cost_each", description: "Wholesale cost per unit", - alternateMatches: ["wholesale", "wholesale price", "supplier cost each"], + alternateMatches: ["wholesale", "wholesale price", "supplier cost each", "cost each"], fieldType: { type: "input", price: true