Fix issues with validation errors showing and problems with concurrent editing, improve scroll position saving

This commit is contained in:
2025-03-18 12:38:23 -04:00
parent 8fdb68fb19
commit 949b543d1f
11 changed files with 2255 additions and 1147 deletions

View File

@@ -1188,7 +1188,6 @@ export const MatchColumnsStep = React.memo(<T extends string>({
})) as unknown as Fields<T> })) as unknown as Fields<T>
const unmatched = findUnmatchedRequiredFields(typedFields, columns); const unmatched = findUnmatchedRequiredFields(typedFields, columns);
console.log("Unmatched required fields:", unmatched);
return unmatched; return unmatched;
}, [fields, columns]) }, [fields, columns])
@@ -1200,7 +1199,6 @@ export const MatchColumnsStep = React.memo(<T extends string>({
// Type assertion to handle the DeepReadonly<T> vs string type mismatch // Type assertion to handle the DeepReadonly<T> vs string type mismatch
return !unmatchedRequiredFields.includes(key as any); return !unmatchedRequiredFields.includes(key as any);
}); });
console.log("Matched required fields:", matched);
return matched; return matched;
}, [requiredFields, unmatchedRequiredFields]); }, [requiredFields, unmatchedRequiredFields]);

View File

@@ -100,8 +100,6 @@ const BaseCellContent = React.memo(({
(field.fieldType.type === 'input' || field.fieldType.type === 'multi-input') && (field.fieldType.type === 'input' || field.fieldType.type === 'multi-input') &&
field.fieldType.price === true; field.fieldType.price === true;
console.log(`BaseCellContent: field.key=${field.key}, fieldType=${fieldType}, disabled=${field.disabled}, options=`, options);
if (fieldType === 'select') { if (fieldType === 'select') {
return ( return (
<SelectCell <SelectCell
@@ -175,20 +173,17 @@ export interface ValidationCellProps {
} }
// Add efficient error message extraction function // Add efficient error message extraction function
const getErrorMessage = (error: ErrorObject): string => error.message;
// Add a utility function to process errors with appropriate caching // Highly optimized error processing function with fast paths for common cases
function processErrors(value: any, errors: ErrorObject[]): { function processErrors(value: any, errors: ErrorObject[]): {
filteredErrors: ErrorObject[];
hasError: boolean; hasError: boolean;
isRequiredButEmpty: boolean; isRequiredButEmpty: boolean;
shouldShowErrorIcon: boolean; shouldShowErrorIcon: boolean;
errorMessages: string; errorMessages: string;
} { } {
// Fast path - if no errors, return immediately // Fast path - if no errors or empty error array, return immediately
if (!errors || errors.length === 0) { if (!errors || errors.length === 0) {
return { return {
filteredErrors: [],
hasError: false, hasError: false,
isRequiredButEmpty: false, isRequiredButEmpty: false,
shouldShowErrorIcon: false, shouldShowErrorIcon: false,
@@ -196,45 +191,37 @@ function processErrors(value: any, errors: ErrorObject[]): {
}; };
} }
// Use the shared isEmpty function instead of defining a local one // Use the shared isEmpty function for value checking
const valueIsEmpty = isEmpty(value); const valueIsEmpty = isEmpty(value);
// If not empty, filter out required errors // Fast path for the most common case - required field with empty value
// Create a new array only if we need to filter (avoid unnecessary allocations) if (valueIsEmpty && errors.length === 1 && errors[0].type === ErrorType.Required) {
let filteredErrors: ErrorObject[]; return {
let hasRequiredError = false; hasError: true,
isRequiredButEmpty: true,
if (valueIsEmpty) { shouldShowErrorIcon: false,
// For empty values, check if there are required errors errorMessages: ''
hasRequiredError = errors.some(error => error.type === ErrorType.Required); };
filteredErrors = errors;
} else {
// For non-empty values, filter out required errors
filteredErrors = errors.filter(error => error.type !== ErrorType.Required);
} }
// Determine if any actual errors exist after filtering // For non-empty values with errors, we need to show error icons
const hasError = filteredErrors.length > 0 && filteredErrors.some(error => const hasError = errors.some(error => error.level === 'error' || error.level === 'warning');
error.level === 'error' || error.level === 'warning'
);
// Check if field is required but empty // For empty values with required errors, show only a border
const isRequiredButEmpty = valueIsEmpty && hasRequiredError; const isRequiredButEmpty = valueIsEmpty && errors.some(error => error.type === ErrorType.Required);
// Only show error icons for non-empty fields with actual errors // Show error icons for non-empty fields with errors, or for empty fields with non-required errors
const shouldShowErrorIcon = hasError && (!valueIsEmpty || !hasRequiredError); const shouldShowErrorIcon = hasError && (!valueIsEmpty || !errors.every(error => error.type === ErrorType.Required));
// Get error messages for the tooltip - only if we need to show icon // Only compute error messages if we're going to show an icon
let errorMessages = ''; const errorMessages = shouldShowErrorIcon
if (shouldShowErrorIcon) { ? errors
errorMessages = filteredErrors .filter(e => e.level === 'error' || e.level === 'warning')
.filter(e => e.level === 'error' || e.level === 'warning') .map(e => e.message)
.map(getErrorMessage) .join('\n')
.join('\n'); : '';
}
return { return {
filteredErrors,
hasError, hasError,
isRequiredButEmpty, isRequiredButEmpty,
shouldShowErrorIcon, shouldShowErrorIcon,
@@ -242,7 +229,7 @@ function processErrors(value: any, errors: ErrorObject[]): {
}; };
} }
// Helper function to compare error arrays efficiently // Helper function to compare error arrays efficiently with a hash-based approach
function compareErrorArrays(prevErrors: ErrorObject[], nextErrors: ErrorObject[]): boolean { function compareErrorArrays(prevErrors: ErrorObject[], nextErrors: ErrorObject[]): boolean {
// Fast path for referential equality // Fast path for referential equality
if (prevErrors === nextErrors) return true; if (prevErrors === nextErrors) return true;
@@ -251,15 +238,21 @@ function compareErrorArrays(prevErrors: ErrorObject[], nextErrors: ErrorObject[]
if (!prevErrors || !nextErrors) return prevErrors === nextErrors; if (!prevErrors || !nextErrors) return prevErrors === nextErrors;
if (prevErrors.length !== nextErrors.length) return false; if (prevErrors.length !== nextErrors.length) return false;
// Check if errors are equivalent // Generate simple hash from error properties
return prevErrors.every((error, index) => { const getErrorHash = (error: ErrorObject): string => {
const nextError = nextErrors[index]; return `${error.message}|${error.level}|${error.type || ''}`;
return ( };
error.message === nextError.message &&
error.level === nextError.level && // Compare using hashes
error.type === nextError.type const prevHashes = prevErrors.map(getErrorHash);
); const nextHashes = nextErrors.map(getErrorHash);
});
// Sort hashes to ensure consistent order
prevHashes.sort();
nextHashes.sort();
// Compare sorted hash arrays
return prevHashes.join(',') === nextHashes.join(',');
} }
const ValidationCell = React.memo(({ const ValidationCell = React.memo(({
@@ -300,15 +293,18 @@ const ValidationCell = React.memo(({
// Add state for hover on target row // Add state for hover on target row
const [isTargetRowHovered, setIsTargetRowHovered] = React.useState(false); const [isTargetRowHovered, setIsTargetRowHovered] = React.useState(false);
// Force isValidating to be a boolean
const isLoading = isValidating === true;
// Handle copy down button click // Handle copy down button click
const handleCopyDownClick = () => { const handleCopyDownClick = React.useCallback(() => {
if (copyDown && totalRows > rowIndex + 1) { if (copyDown && totalRows > rowIndex + 1) {
// Enter copy down mode // Enter copy down mode
copyDownContext.setIsInCopyDownMode(true); copyDownContext.setIsInCopyDownMode(true);
copyDownContext.setSourceRowIndex(rowIndex); copyDownContext.setSourceRowIndex(rowIndex);
copyDownContext.setSourceFieldKey(fieldKey); copyDownContext.setSourceFieldKey(fieldKey);
} }
}; }, [copyDown, copyDownContext, fieldKey, rowIndex, totalRows]);
// Check if this cell is in a row that can be a target for copy down // Check if this cell is in a row that can be a target for copy down
const isInTargetRow = copyDownContext.isInCopyDownMode && const isInTargetRow = copyDownContext.isInCopyDownMode &&
@@ -319,7 +315,7 @@ const ValidationCell = React.memo(({
const isSelectedTarget = isInTargetRow && rowIndex <= (copyDownContext.targetRowIndex || 0); const isSelectedTarget = isInTargetRow && rowIndex <= (copyDownContext.targetRowIndex || 0);
// Handle click on a potential target cell // Handle click on a potential target cell
const handleTargetCellClick = () => { const handleTargetCellClick = React.useCallback(() => {
if (isInTargetRow && copyDownContext.sourceRowIndex !== null && copyDownContext.sourceFieldKey !== null) { if (isInTargetRow && copyDownContext.sourceRowIndex !== null && copyDownContext.sourceFieldKey !== null) {
copyDownContext.handleCopyDownComplete( copyDownContext.handleCopyDownComplete(
copyDownContext.sourceRowIndex, copyDownContext.sourceRowIndex,
@@ -327,22 +323,35 @@ const ValidationCell = React.memo(({
rowIndex rowIndex
); );
} }
}; }, [copyDownContext, isInTargetRow, rowIndex]);
// Memoize the cell style objects to avoid recreating them on every render
const cellStyle = React.useMemo(() => ({
width: `${width}px`,
minWidth: `${width}px`,
maxWidth: `${width}px`,
boxSizing: 'border-box' as const,
cursor: isInTargetRow ? 'pointer' : undefined,
...(isSourceCell ? { backgroundColor: '#dbeafe', borderRadius: '0.375rem', padding: 0, boxShadow: '0 0 0 2px #3b82f6' } :
isSelectedTarget ? { backgroundColor: '#bfdbfe', borderRadius: '0.375rem', padding: 0 } :
isInTargetRow && isTargetRowHovered ? { backgroundColor: '#dbeafe', borderRadius: '0.375rem', padding: 0, cursor: 'pointer' } :
isInTargetRow ? { borderRadius: '0.375rem', padding: 0, cursor: 'pointer' } : {})
}), [width, isInTargetRow, isSourceCell, isSelectedTarget, isTargetRowHovered]);
// Memoize the cell class name to prevent re-calculating on every render
const cellClassName = React.useMemo(() => {
if (isSourceCell || isSelectedTarget || isInTargetRow) {
return isSourceCell ? '!bg-blue-100 !border-blue-500 !rounded-md' :
isSelectedTarget ? '!bg-blue-200 !border-blue-200 !rounded-md' :
isInTargetRow ? 'hover:!bg-blue-100 !border-blue-200 !rounded-md' : '';
}
return '';
}, [isSourceCell, isSelectedTarget, isInTargetRow]);
return ( return (
<TableCell <TableCell
className="p-1 group relative" className="p-1 group relative"
style={{ style={cellStyle}
width: `${width}px`,
minWidth: `${width}px`,
maxWidth: `${width}px`,
boxSizing: 'border-box',
cursor: isInTargetRow ? 'pointer' : undefined,
...(isSourceCell ? { backgroundColor: '#dbeafe', borderRadius: '0.375rem', padding: 0, boxShadow: '0 0 0 2px #3b82f6' } :
isSelectedTarget ? { backgroundColor: '#bfdbfe', borderRadius: '0.375rem', padding: 0 } :
isInTargetRow && isTargetRowHovered ? { backgroundColor: '#dbeafe', borderRadius: '0.375rem', padding: 0, cursor: 'pointer' } :
isInTargetRow ? { borderRadius: '0.375rem', padding: 0, cursor: 'pointer' } : {})
}}
onClick={isInTargetRow ? handleTargetCellClick : undefined} onClick={isInTargetRow ? handleTargetCellClick : undefined}
onMouseEnter={isInTargetRow ? () => setIsTargetRowHovered(true) : undefined} onMouseEnter={isInTargetRow ? () => setIsTargetRowHovered(true) : undefined}
onMouseLeave={isInTargetRow ? () => setIsTargetRowHovered(false) : undefined} onMouseLeave={isInTargetRow ? () => setIsTargetRowHovered(false) : undefined}
@@ -401,25 +410,20 @@ const ValidationCell = React.memo(({
</TooltipProvider> </TooltipProvider>
</div> </div>
)} )}
{isValidating ? ( {isLoading ? (
<div className={`flex items-center justify-center gap-2 border ${hasError || isRequiredButEmpty ? 'border-red-500' : 'border-input'} rounded-sm px-2 py-1.5`}> <div className={`flex items-center justify-center gap-2 border ${hasError || isRequiredButEmpty ? 'border-red-500' : 'border-input'} rounded-sm px-2 py-1.5`}>
<Loader2 className="h-4 w-4 animate-spin text-blue-500" /> <Loader2 className="h-4 w-4 animate-spin text-blue-500" />
<span>Loading...</span> <span>Loading...</span>
</div> </div>
) : ( ) : (
<div className={`truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}> <div className={`truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}>
{(() => { console.log(`ValidationCell: fieldKey=${fieldKey}, options=`, options); return null; })()}
<BaseCellContent <BaseCellContent
field={field} field={field}
value={displayValue} value={displayValue}
onChange={onChange} onChange={onChange}
hasErrors={hasError || isRequiredButEmpty} hasErrors={hasError || isRequiredButEmpty}
options={options} options={options}
className={isSourceCell || isSelectedTarget || isInTargetRow ? `${ className={cellClassName}
isSourceCell ? '!bg-blue-100 !border-blue-500 !rounded-md' :
isSelectedTarget ? '!bg-blue-200 !border-blue-200 !rounded-md' :
isInTargetRow ? 'hover:!bg-blue-100 !border-blue-200 !rounded-md' : ''
}` : ''}
/> />
</div> </div>
)} )}
@@ -427,32 +431,48 @@ const ValidationCell = React.memo(({
</TableCell> </TableCell>
); );
}, (prevProps, nextProps) => { }, (prevProps, nextProps) => {
// Deep compare the most important props to avoid unnecessary re-renders // Fast path: if all props are the same object
const valueEqual = prevProps.value === nextProps.value; if (prevProps === nextProps) return true;
const isValidatingEqual = prevProps.isValidating === nextProps.isValidating;
const fieldEqual = prevProps.field === nextProps.field;
const itemNumberEqual = prevProps.itemNumber === nextProps.itemNumber;
// Use enhanced error comparison // Optimize the memo comparison function, checking most impactful props first
const errorsEqual = compareErrorArrays(prevProps.errors, nextProps.errors); // Check isValidating first as it's most likely to change frequently
if (prevProps.isValidating !== nextProps.isValidating) return false;
// Shallow options comparison with length check // Then check value changes
const optionsEqual = if (prevProps.value !== nextProps.value) return false;
prevProps.options === nextProps.options ||
(Array.isArray(prevProps.options) &&
Array.isArray(nextProps.options) &&
prevProps.options.length === nextProps.options.length &&
prevProps.options.every((opt, idx) => {
// Handle safely when options might be undefined
const nextOptions = nextProps.options || [];
return opt === nextOptions[idx];
}));
// Skip comparison of props that rarely change // Item number is related to validation state
// (rowIndex, width, copyDown, totalRows) if (prevProps.itemNumber !== nextProps.itemNumber) return false;
return valueEqual && isValidatingEqual && fieldEqual && errorsEqual && // Check errors with our optimized comparison function
optionsEqual && itemNumberEqual; if (!compareErrorArrays(prevProps.errors, nextProps.errors)) return false;
// Check field identity
if (prevProps.field !== nextProps.field) return false;
// Shallow options comparison - only if field type is select or multi-select
if (prevProps.field.fieldType?.type === 'select' || prevProps.field.fieldType?.type === 'multi-select') {
const optionsEqual = prevProps.options === nextProps.options ||
(Array.isArray(prevProps.options) &&
Array.isArray(nextProps.options) &&
prevProps.options.length === nextProps.options.length &&
prevProps.options.every((opt, idx) => {
const nextOptions = nextProps.options || [];
return opt === nextOptions[idx];
}));
if (!optionsEqual) return false;
}
// Check copy down context changes
const copyDownContextChanged =
prevProps.rowIndex !== nextProps.rowIndex ||
prevProps.fieldKey !== nextProps.fieldKey;
if (copyDownContextChanged) return false;
// All essential props are the same - we can skip re-rendering
return true;
}); });
ValidationCell.displayName = 'ValidationCell'; ValidationCell.displayName = 'ValidationCell';

View File

@@ -9,6 +9,7 @@ import SearchableTemplateSelect from './SearchableTemplateSelect'
import { useAiValidation } from '../hooks/useAiValidation' import { useAiValidation } from '../hooks/useAiValidation'
import { AiValidationDialogs } from './AiValidationDialogs' import { AiValidationDialogs } from './AiValidationDialogs'
import { Fields } from '../../../types' import { Fields } from '../../../types'
import { ErrorType, ValidationError, ErrorSources } from '../../../types'
import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog' import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog'
import { TemplateForm } from '@/components/templates/TemplateForm' import { TemplateForm } from '@/components/templates/TemplateForm'
import axios from 'axios' import axios from 'axios'
@@ -16,6 +17,7 @@ import { RowSelectionState } from '@tanstack/react-table'
import { useUpcValidation } from '../hooks/useUpcValidation' import { useUpcValidation } from '../hooks/useUpcValidation'
import { useProductLinesFetching } from '../hooks/useProductLinesFetching' import { useProductLinesFetching } from '../hooks/useProductLinesFetching'
import UpcValidationTableAdapter from './UpcValidationTableAdapter' import UpcValidationTableAdapter from './UpcValidationTableAdapter'
import { clearAllUniquenessCaches } from '../hooks/useValidation'
/** /**
* ValidationContainer component - the main wrapper for the validation step * ValidationContainer component - the main wrapper for the validation step
@@ -58,7 +60,7 @@ const ValidationContainer = <T extends string>({
loadTemplates, loadTemplates,
setData, setData,
fields, fields,
isLoadingTemplates } = validationState isLoadingTemplates } = validationState
// Use product lines fetching hook // Use product lines fetching hook
const { const {
@@ -114,6 +116,58 @@ const ValidationContainer = <T extends string>({
const [templateFormInitialData, setTemplateFormInitialData] = useState<any>(null) const [templateFormInitialData, setTemplateFormInitialData] = useState<any>(null)
const [fieldOptions, setFieldOptions] = useState<any>(null) const [fieldOptions, setFieldOptions] = useState<any>(null)
// Track fields that need revalidation due to value changes
const [fieldsToRevalidate, setFieldsToRevalidate] = useState<Set<number>>(new Set());
const [fieldsToRevalidateMap, setFieldsToRevalidateMap] = useState<{[rowIndex: number]: string[]}>({});
// Function to mark a row for revalidation
const markRowForRevalidation = useCallback((rowIndex: number, fieldKey?: string) => {
setFieldsToRevalidate(prev => {
const newSet = new Set(prev);
newSet.add(rowIndex);
return newSet;
});
// Also track which specific field needs to be revalidated
if (fieldKey) {
setFieldsToRevalidateMap(prev => {
const newMap = { ...prev };
if (!newMap[rowIndex]) {
newMap[rowIndex] = [];
}
if (!newMap[rowIndex].includes(fieldKey)) {
newMap[rowIndex] = [...newMap[rowIndex], fieldKey];
}
return newMap;
});
}
}, []);
// Add a ref to track the last validation time
const lastValidationTime = useRef(0);
// Trigger revalidation only for specifically marked fields
useEffect(() => {
if (fieldsToRevalidate.size === 0) return;
// Revalidate the marked rows
const rowsToRevalidate = Array.from(fieldsToRevalidate);
// Clear the revalidation set
setFieldsToRevalidate(new Set());
// Get the fields map for revalidation
const fieldsMap = { ...fieldsToRevalidateMap };
// 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]);
// Function to fetch field options for template form // Function to fetch field options for template form
const fetchFieldOptions = useCallback(async () => { const fetchFieldOptions = useCallback(async () => {
try { try {
@@ -246,14 +300,105 @@ const ValidationContainer = <T extends string>({
} }
}, [prepareRowDataForTemplateForm, fetchFieldOptions]); }, [prepareRowDataForTemplateForm, fetchFieldOptions]);
// Create a function to validate uniqueness if validateUniqueItemNumbers is not available
const validateUniqueValues = useCallback(() => {
// Check if validateUniqueItemNumbers exists on validationState using safer method
if ('validateUniqueItemNumbers' in validationState &&
typeof (validationState as any).validateUniqueItemNumbers === 'function') {
(validationState as any).validateUniqueItemNumbers();
} else {
// Otherwise fall back to revalidating all rows
validationState.revalidateRows(Array.from(Array(data.length).keys()));
}
}, [validationState, data.length]);
// Apply item numbers to data and trigger revalidation for uniqueness
const applyItemNumbersAndValidate = useCallback(() => {
// Clear uniqueness validation caches to ensure fresh validation
clearAllUniquenessCaches();
upcValidation.applyItemNumbersToData((updatedRowIds) => {
console.log(`Revalidating item numbers for ${updatedRowIds.length} rows`);
// Force clearing all uniqueness errors for item_number and upc fields first
const newValidationErrors = new Map(validationErrors);
// Clear uniqueness errors for all rows that had their item numbers updated
updatedRowIds.forEach(rowIndex => {
const rowErrors = newValidationErrors.get(rowIndex);
if (rowErrors) {
// Create a copy of row errors without uniqueness errors for item_number/upc
const filteredErrors: Record<string, ValidationError[]> = { ...rowErrors };
let hasChanges = false;
// Clear item_number errors if they exist and are uniqueness errors
if (filteredErrors.item_number &&
filteredErrors.item_number.some(e => e.type === ErrorType.Unique)) {
delete filteredErrors.item_number;
hasChanges = true;
}
// Also clear upc/barcode errors if they exist and are uniqueness errors
if (filteredErrors.upc &&
filteredErrors.upc.some(e => e.type === ErrorType.Unique)) {
delete filteredErrors.upc;
hasChanges = true;
}
if (filteredErrors.barcode &&
filteredErrors.barcode.some(e => e.type === ErrorType.Unique)) {
delete filteredErrors.barcode;
hasChanges = true;
}
// Update the map or remove the row entry if no errors remain
if (hasChanges) {
if (Object.keys(filteredErrors).length > 0) {
newValidationErrors.set(rowIndex, filteredErrors);
} else {
newValidationErrors.delete(rowIndex);
}
}
}
});
// Call the revalidateRows function directly with affected rows
validationState.revalidateRows(updatedRowIds);
// Immediately run full uniqueness validation across all rows if available
// This is crucial to properly identify new uniqueness issues
setTimeout(() => {
validateUniqueValues();
}, 0);
// Mark all updated rows for revalidation
updatedRowIds.forEach(rowIndex => {
markRowForRevalidation(rowIndex, 'item_number');
});
});
}, [upcValidation.applyItemNumbersToData, markRowForRevalidation, clearAllUniquenessCaches, validationErrors, validationState.revalidateRows, validateUniqueValues]);
// Handle next button click - memoized // Handle next button click - memoized
const handleNext = useCallback(() => { const handleNext = useCallback(() => {
// Make sure any pending item numbers are applied // Make sure any pending item numbers are applied
upcValidation.applyItemNumbersToData(); upcValidation.applyItemNumbersToData(updatedRowIds => {
// Mark updated rows for revalidation
updatedRowIds.forEach(rowIndex => {
markRowForRevalidation(rowIndex, 'item_number');
});
// Small delay to ensure all validations complete before proceeding
setTimeout(() => {
// Call the onNext callback with the validated data
onNext?.(data);
}, 100);
});
// Call the onNext callback with the validated data // If no item numbers to apply, just proceed
onNext?.(data) if (upcValidation.validatingRows.size === 0) {
}, [onNext, data, upcValidation.applyItemNumbersToData]); onNext?.(data);
}
}, [onNext, data, upcValidation, markRowForRevalidation]);
const deleteSelectedRows = useCallback(() => { const deleteSelectedRows = useCallback(() => {
// Get selected row keys (which may be UUIDs) // Get selected row keys (which may be UUIDs)
@@ -331,9 +476,73 @@ const ValidationContainer = <T extends string>({
setRowSelection(newSelection); setRowSelection(newSelection);
}, [setRowSelection]); }, [setRowSelection]);
// Add scroll container ref at the container level
const scrollContainerRef = useRef<HTMLDivElement>(null);
const lastScrollPosition = useRef({ left: 0, top: 0 });
const isScrolling = useRef(false);
// Track if we're currently validating a UPC
const isValidatingUpcRef = useRef(false);
// Track last UPC update to prevent conflicting changes
const lastUpcUpdate = useRef({
rowIndex: -1,
supplier: "",
upc: ""
});
// Add these ref declarations here, at component level
const lastCompanyFetchTime = useRef<Record<string, number>>({});
const lastLineFetchTime = useRef<Record<string, number>>({});
// Memoize scroll handlers - simplified to avoid performance issues
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement> | Event) => {
// Store scroll position directly without conditions
const target = event.currentTarget as HTMLDivElement;
lastScrollPosition.current = {
left: target.scrollLeft,
top: target.scrollTop
};
}, []);
// Add scroll event listener
useEffect(() => {
const container = scrollContainerRef.current;
if (container) {
// Convert React event handler to native event handler
const nativeHandler = ((evt: Event) => {
handleScroll(evt);
}) as EventListener;
container.addEventListener('scroll', nativeHandler, { passive: true });
return () => container.removeEventListener('scroll', nativeHandler);
}
}, [handleScroll]);
// Use a ref to track if we need to restore scroll position
const needScrollRestore = useRef(false);
// Set flag when data changes
useEffect(() => {
needScrollRestore.current = true;
// Only restore scroll on layout effects to avoid triggering rerenders
}, []);
// Use layout effect for DOM manipulations
useLayoutEffect(() => {
if (!needScrollRestore.current) return;
const container = scrollContainerRef.current;
if (container && (lastScrollPosition.current.left > 0 || lastScrollPosition.current.top > 0)) {
container.scrollLeft = lastScrollPosition.current.left;
container.scrollTop = lastScrollPosition.current.top;
needScrollRestore.current = false;
}
}, []);
// Ensure manual edits to item numbers persist with minimal changes to validation logic
const handleUpdateRow = useCallback(async (rowIndex: number, key: T, value: any) => { const handleUpdateRow = useCallback(async (rowIndex: number, key: T, value: any) => {
// Process value before updating data // Process value before updating data
console.log(`enhancedUpdateRow called: rowIndex=${rowIndex}, fieldKey=${key}, value=`, value);
let processedValue = value; let processedValue = value;
// Strip dollar signs from price fields // Strip dollar signs from price fields
@@ -347,158 +556,272 @@ const ValidationContainer = <T extends string>({
} }
} }
// Save current scroll position // Find the row in the data
const scrollPosition = {
left: window.scrollX,
top: window.scrollY
};
// Find the original index in the data array
const rowData = filteredData[rowIndex]; const rowData = filteredData[rowIndex];
const originalIndex = data.findIndex(item => item.__index === rowData?.__index); if (!rowData) {
console.error(`No row data found for index ${rowIndex}`);
return;
}
if (originalIndex === -1) { // Use __index to find the actual row in the full data array
// If we can't find the original row, just do a simple update const rowId = rowData.__index;
updateRow(rowIndex, key, processedValue); const originalIndex = data.findIndex(item => item.__index === rowId);
} else {
// Update the data directly // Detect if this is a direct item_number edit
const isItemNumberEdit = key === 'item_number' as T;
// For item_number edits, we need special handling to ensure they persist
if (isItemNumberEdit) {
console.log(`Manual edit to item_number: ${value}`);
// First, update data immediately to ensure edit takes effect
setData(prevData => { setData(prevData => {
const newData = [...prevData]; const newData = [...prevData];
const updatedRow = { if (originalIndex >= 0 && originalIndex < newData.length) {
newData[originalIndex] = {
...newData[originalIndex],
[key]: processedValue
};
}
return newData;
});
// Mark for revalidation after a delay to ensure data update completes first
setTimeout(() => {
markRowForRevalidation(rowIndex, key as string);
}, 0);
// Return early to prevent double-updating
return;
}
// For all other fields, use standard approach
// Always use setData for updating - immediate update for better UX
const updatedRow = { ...rowData, [key]: processedValue };
// Mark this row for revalidation to clear any existing errors
markRowForRevalidation(rowIndex, key as string);
// Update the data immediately to show the change
setData(prevData => {
const newData = [...prevData];
if (originalIndex >= 0 && originalIndex < newData.length) {
// Create a new row object with the updated field
newData[originalIndex] = {
...newData[originalIndex], ...newData[originalIndex],
[key]: processedValue [key]: processedValue
}; };
}
newData[originalIndex] = updatedRow; return newData;
return newData; });
});
}
// Restore scroll position after update // Secondary effects - using a timeout to ensure UI updates first
setTimeout(() => { setTimeout(() => {
window.scrollTo(scrollPosition.left, scrollPosition.top); // Handle company change - clear line/subline and fetch product lines
}, 0); if (key === 'company' && value) {
console.log(`Company changed to ${value} for row ${rowIndex}, updating lines and sublines`);
// Now handle any additional logic for specific fields
if (key === 'company' && value) {
// Clear any existing line/subline values for this row if company changes
if (originalIndex !== -1) {
setData(prevData => {
const newData = [...prevData];
newData[originalIndex] = {
...newData[originalIndex],
line: undefined,
subline: undefined
};
return newData;
});
}
// Use cached product lines if available, otherwise fetch
if (rowData && rowData.__index) {
const companyId = value.toString();
if (rowProductLines[companyId]) {
// Use cached data
console.log(`Using cached product lines for company ${companyId}`);
} else {
// Fetch product lines for the new company
if (value !== undefined) {
await fetchProductLines(rowData.__index as string, companyId);
}
}
}
}
// If updating supplier field AND there's a UPC value, validate UPC
if (key === 'supplier' && value && rowData) {
const rowDataAny = rowData as Record<string, any>;
if (rowDataAny.upc || rowDataAny.barcode) {
const upcValue = rowDataAny.upc || rowDataAny.barcode;
try { // Clear any existing line/subline values immediately
// Mark the item_number cell as being validated
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.add(`${rowIndex}-item_number`);
return newSet;
});
// Use supplier ID (the value being set) to validate UPC
await upcValidation.validateUpc(rowIndex, value.toString(), upcValue.toString());
} catch (error) {
console.error('Error validating UPC:', error);
} finally {
// Clear validation state for the item_number cell
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.delete(`${rowIndex}-item_number`);
return newSet;
});
}
}
}
// If updating line field, fetch sublines
if (key === 'line' && value) {
// Clear any existing subline value for this row
if (originalIndex !== -1) {
setData(prevData => { setData(prevData => {
const newData = [...prevData]; const newData = [...prevData];
newData[originalIndex] = { const idx = newData.findIndex(item => item.__index === rowId);
...newData[originalIndex], if (idx >= 0) {
subline: undefined 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; return newData;
}); });
}
// Fetch product lines for the new company
// Use cached sublines if available, otherwise fetch if (rowId && value !== undefined) {
if (rowData && rowData.__index) { const companyId = value.toString();
const lineId = value.toString();
if (rowSublines[lineId]) { // Force immediate fetch for better UX
// Use cached data console.log(`Immediately fetching product lines for company ${companyId} for row ${rowId}`);
console.log(`Using cached sublines for line ${lineId}`);
} else { // Set loading state first
// Fetch sublines for the new line
if (value !== undefined) {
await fetchSublines(rowData.__index as string, lineId);
}
}
}
}
// If updating UPC/barcode field AND there's a supplier value, validate UPC
if ((key === 'upc' || key === 'barcode') && value && rowData) {
const rowDataAny = rowData as Record<string, any>;
if (rowDataAny.supplier) {
try {
// Mark the item_number cell as being validated
setValidatingCells(prev => { setValidatingCells(prev => {
const newSet = new Set(prev); const newSet = new Set(prev);
newSet.add(`${rowIndex}-item_number`); newSet.add(`${rowIndex}-line`);
return newSet; return newSet;
}); });
// Use supplier ID from the row data to validate UPC fetchProductLines(rowId, companyId)
await upcValidation.validateUpc(rowIndex, rowDataAny.supplier.toString(), value.toString()); .then(lines => {
} catch (error) { console.log(`Successfully loaded ${lines.length} product lines for company ${companyId}`);
console.error('Error validating UPC:', error); })
} finally { .catch(err => {
// Clear validation state for the item_number cell console.error(`Error fetching product lines for company ${companyId}:`, err);
setValidatingCells(prev => { toast.error("Failed to load product lines");
const newSet = new Set(prev); })
newSet.delete(`${rowIndex}-item_number`); .finally(() => {
return newSet; // Clear loading indicator
}); setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.delete(`${rowIndex}-line`);
return newSet;
});
});
} }
} }
}
}, [data, filteredData, updateRow, fetchProductLines, fetchSublines, setData, rowProductLines, rowSublines, upcValidation]); // Handle supplier + UPC validation - using the most recent values
if (key === 'supplier' && value) {
// Get the latest UPC value from the updated row
const upcValue = updatedRow.upc || updatedRow.barcode;
if (upcValue) {
console.log(`Validating UPC: rowIndex=${rowIndex}, supplier=${value}, upc=${upcValue}`);
// Mark the item_number cell as being validated
const cellKey = `${rowIndex}-item_number`;
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.add(cellKey);
return newSet;
});
// Use a regular promise-based approach instead of await
upcValidation.validateUpc(rowIndex, value.toString(), upcValue.toString())
.then(result => {
if (result.success) {
console.log(`UPC validation successful for row ${rowIndex}`);
upcValidation.applyItemNumbersToData();
// Mark for revalidation after item numbers are updated
setTimeout(() => {
markRowForRevalidation(rowIndex, 'item_number');
}, 50);
}
})
.catch(err => {
console.error("Error validating UPC:", err);
})
.finally(() => {
// Clear validation state for the item_number cell
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.delete(cellKey);
return newSet;
});
});
}
}
// Handle line change - clear subline and fetch sublines
if (key === 'line' && value) {
console.log(`Line changed to ${value} for row ${rowIndex}, updating sublines`);
// Clear any existing subline value
setData(prevData => {
const newData = [...prevData];
const idx = newData.findIndex(item => item.__index === rowId);
if (idx >= 0) {
console.log(`Clearing subline values for row with ID ${rowId}`);
newData[idx] = {
...newData[idx],
subline: undefined
};
} else {
console.warn(`Could not find row with ID ${rowId} to clear subline values`);
}
return newData;
});
// Fetch sublines for the new line
if (rowId && value !== undefined) {
const lineId = value.toString();
// Force immediate fetch for better UX
console.log(`Immediately fetching sublines for line ${lineId} for row ${rowId}`);
// Set loading state first
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.add(`${rowIndex}-subline`);
return newSet;
});
fetchSublines(rowId, lineId)
.then(sublines => {
console.log(`Successfully loaded ${sublines.length} sublines for line ${lineId}`);
})
.catch(err => {
console.error(`Error fetching sublines for line ${lineId}:`, err);
toast.error("Failed to load sublines");
})
.finally(() => {
// Clear loading indicator
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.delete(`${rowIndex}-subline`);
return newSet;
});
});
}
}
// Add the UPC/barcode validation handler back:
// Handle UPC/barcode + supplier validation
if ((key === 'upc' || key === 'barcode') && value) {
// Get latest supplier from the updated row
const supplier = updatedRow.supplier;
if (supplier) {
console.log(`Validating UPC from UPC change: rowIndex=${rowIndex}, supplier=${supplier}, upc=${value}`);
// Mark the item_number cell as being validated
const cellKey = `${rowIndex}-item_number`;
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.add(cellKey);
return newSet;
});
// Use a regular promise-based approach
upcValidation.validateUpc(rowIndex, supplier.toString(), value.toString())
.then(result => {
if (result.success) {
console.log(`UPC validation successful for row ${rowIndex}`);
upcValidation.applyItemNumbersToData();
// Mark for revalidation after item numbers are updated
setTimeout(() => {
markRowForRevalidation(rowIndex, 'item_number');
}, 50);
}
})
.catch(err => {
console.error("Error validating UPC:", err);
})
.finally(() => {
// Clear validation state for the item_number cell
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.delete(cellKey);
return newSet;
});
});
}
}
}, 0); // Using 0ms timeout to defer execution until after the UI update
}, [data, filteredData, setData, fetchProductLines, fetchSublines, upcValidation, markRowForRevalidation]);
// Create a separate copyDown function that uses handleUpdateRow // Fix the missing loading indicator clear code
const handleCopyDown = useCallback((rowIndex: number, fieldKey: string, endRowIndex?: number) => { const handleCopyDown = useCallback((rowIndex: number, fieldKey: string, endRowIndex?: number) => {
// Get the value to copy from the source row // Get the value to copy from the source row
const sourceRow = data[rowIndex]; const sourceRow = data[rowIndex];
if (!sourceRow) {
console.error(`Source row ${rowIndex} not found for copyDown`);
return;
}
const valueToCopy = sourceRow[fieldKey]; const valueToCopy = sourceRow[fieldKey];
// Create a proper copy of the value to avoid reference issues, especially for arrays (MultiSelectCell) // Create a proper copy of the value to avoid reference issues, especially for arrays (MultiSelectCell)
@@ -506,50 +829,183 @@ const ValidationContainer = <T extends string>({
// Get all rows below the source row, up to endRowIndex if specified // Get all rows below the source row, up to endRowIndex if specified
const lastRowIndex = endRowIndex !== undefined ? Math.min(endRowIndex, data.length - 1) : data.length - 1; const lastRowIndex = endRowIndex !== undefined ? Math.min(endRowIndex, data.length - 1) : data.length - 1;
const rowsToUpdate = data.slice(rowIndex + 1, lastRowIndex + 1); const rowsToUpdate = Array.from({ length: lastRowIndex - rowIndex }, (_, i) => rowIndex + i + 1);
// Create a set of cells that will be in loading state // Mark all cells as updating at once
const loadingCells = new Set<string>(); const updatingCells = new Set<string>();
rowsToUpdate.forEach(targetRowIndex => {
// Add all target cells to the loading state updatingCells.add(`${targetRowIndex}-${fieldKey}`);
rowsToUpdate.forEach((_, index) => {
const targetRowIndex = rowIndex + 1 + index;
loadingCells.add(`${targetRowIndex}-${fieldKey}`);
}); });
// Update validatingCells to show loading state
setValidatingCells(prev => { setValidatingCells(prev => {
const newSet = new Set(prev); const newSet = new Set(prev);
loadingCells.forEach(cell => newSet.add(cell)); updatingCells.forEach(cell => newSet.add(cell));
return newSet; return newSet;
}); });
// Update all rows immediately // Update all rows at once efficiently with a single state update
rowsToUpdate.forEach((_, i) => { setData(prevData => {
const targetRowIndex = rowIndex + 1 + i; // Create a new copy of the data
const newData = [...prevData];
// Update the row with the copied value // Update all rows at once
handleUpdateRow(targetRowIndex, fieldKey as T, valueCopy); rowsToUpdate.forEach(targetRowIndex => {
// Find the original row using __index
const rowData = filteredData[targetRowIndex];
if (!rowData) return;
const rowId = rowData.__index;
const originalIndex = newData.findIndex(item => item.__index === rowId);
if (originalIndex !== -1) {
// Update the specific field on this row
newData[originalIndex] = {
...newData[originalIndex],
[fieldKey]: valueCopy
};
} else {
// Fall back to direct index if __index not found
if (targetRowIndex < newData.length) {
newData[targetRowIndex] = {
...newData[targetRowIndex],
[fieldKey]: valueCopy
};
}
}
});
// Remove loading state return newData;
});
// Mark rows for revalidation
rowsToUpdate.forEach(targetRowIndex => {
markRowForRevalidation(targetRowIndex, fieldKey);
});
// Clear the loading state for all cells after a short delay
setTimeout(() => {
setValidatingCells(prev => { setValidatingCells(prev => {
if (prev.size === 0) return prev;
const newSet = new Set(prev); const newSet = new Set(prev);
newSet.delete(`${targetRowIndex}-${fieldKey}`); updatingCells.forEach(cell => newSet.delete(cell));
return newSet; return newSet;
}); });
}); }, 100);
}, [data, handleUpdateRow, setValidatingCells]);
// Use UPC validation when data changes
useEffect(() => {
// Skip if there's no data or already validated
if (data.length === 0 || upcValidation.initialValidationDone) return;
// Run validation immediately without timeout // If copying UPC or supplier fields, validate UPC for all rows
upcValidation.validateAllUPCs(); if (fieldKey === 'upc' || fieldKey === 'barcode' || fieldKey === 'supplier') {
// Process each row in parallel
// No cleanup needed since we're not using a timer const validationsToRun: {rowIndex: number, supplier: string, upc: string}[] = [];
}, [data, upcValidation]);
// Process each row separately to collect validation tasks
rowsToUpdate.forEach(targetRowIndex => {
const rowData = filteredData[targetRowIndex];
if (!rowData) return;
// Only validate if both UPC and supplier are present after the update
const updatedRow = {
...rowData,
[fieldKey]: valueCopy
};
const hasUpc = updatedRow.upc || updatedRow.barcode;
const hasSupplier = updatedRow.supplier;
if (hasUpc && hasSupplier) {
const upcValue = updatedRow.upc || updatedRow.barcode;
const supplierId = updatedRow.supplier;
// Queue this validation if both values are defined
if (supplierId !== undefined && upcValue !== undefined) {
validationsToRun.push({
rowIndex: targetRowIndex,
supplier: supplierId.toString(),
upc: upcValue.toString()
});
}
}
});
// Run validations in parallel but limit the batch size
if (validationsToRun.length > 0) {
console.log(`Running ${validationsToRun.length} UPC validations for copyDown`);
// Mark all cells as validating
validationsToRun.forEach(({ rowIndex }) => {
const cellKey = `${rowIndex}-item_number`;
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.add(cellKey);
return newSet;
});
});
// Process in smaller batches to avoid overwhelming the system
const BATCH_SIZE = 5; // Process 5 validations at a time
const processBatch = (startIdx: number) => {
const endIdx = Math.min(startIdx + BATCH_SIZE, validationsToRun.length);
const batch = validationsToRun.slice(startIdx, endIdx);
Promise.all(
batch.map(({ rowIndex, supplier, upc }) =>
upcValidation.validateUpc(rowIndex, supplier, upc)
.then(result => {
if (result.success) {
// Apply immediately for better UX
if (startIdx + BATCH_SIZE >= validationsToRun.length) {
// Apply all updates at the end with callback to mark for revalidation
upcValidation.applyItemNumbersToData(updatedRowIds => {
// Mark these rows for revalidation after a delay
setTimeout(() => {
updatedRowIds.forEach(rowIdx => {
markRowForRevalidation(rowIdx, 'item_number');
});
}, 100);
});
}
}
return { rowIndex, success: result.success };
})
.catch(err => {
console.error(`Error validating UPC for row ${rowIndex}:`, err);
return { rowIndex, success: false };
})
.finally(() => {
// Clear validation state for this cell
const cellKey = `${rowIndex}-item_number`;
setValidatingCells(prev => {
if (!prev.has(cellKey)) return prev;
const newSet = new Set(prev);
newSet.delete(cellKey);
return newSet;
});
})
)
).then(() => {
// If there are more validations to run, process the next batch
if (endIdx < validationsToRun.length) {
// Add a small delay between batches to prevent UI freezing
setTimeout(() => processBatch(endIdx), 100);
} else {
console.log(`Completed all ${validationsToRun.length} UPC validations`);
// Final application of all item numbers if not done by individual batches
upcValidation.applyItemNumbersToData(updatedRowIds => {
// Mark these rows for revalidation after a delay
setTimeout(() => {
updatedRowIds.forEach(rowIdx => {
markRowForRevalidation(rowIdx, 'item_number');
});
}, 100);
});
}
});
};
// Start processing the first batch
processBatch(0);
}
}
}, [data, filteredData, setData, setValidatingCells, upcValidation, markRowForRevalidation]);
// Memoize the rendered validation table // Memoize the rendered validation table
const renderValidationTable = useMemo(() => { const renderValidationTable = useMemo(() => {
@@ -611,74 +1067,6 @@ const ValidationContainer = <T extends string>({
isLoadingSublines isLoadingSublines
]); ]);
// Add scroll container ref at the container level
const scrollContainerRef = useRef<HTMLDivElement>(null);
const lastScrollPosition = useRef({ left: 0, top: 0 });
const isScrolling = useRef(false);
// Memoize scroll handlers
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement> | Event) => {
if (!isScrolling.current) {
isScrolling.current = true;
// Use type assertion to handle both React.UIEvent and native Event
const target = event.currentTarget as HTMLDivElement;
lastScrollPosition.current = {
left: target.scrollLeft,
top: target.scrollTop
};
requestAnimationFrame(() => {
isScrolling.current = false;
});
}
}, []);
// Add scroll event listener
useEffect(() => {
const container = scrollContainerRef.current;
if (container) {
// Convert React event handler to native event handler
const nativeHandler = ((evt: Event) => {
handleScroll(evt);
}) as EventListener;
container.addEventListener('scroll', nativeHandler, { passive: true });
return () => container.removeEventListener('scroll', nativeHandler);
}
}, [handleScroll]);
// Restore scroll position after data updates
useLayoutEffect(() => {
const container = scrollContainerRef.current;
if (container) {
const { left, top } = lastScrollPosition.current;
if (left > 0 || top > 0) {
requestAnimationFrame(() => {
if (container) {
container.scrollTo({
left,
top,
behavior: 'auto'
});
}
});
}
}
}, [filteredData]);
// Add cleanup effect to reset scroll position when component unmounts
useEffect(() => {
return () => {
// Reset the last scroll position reference
lastScrollPosition.current = { left: 0, top: 0 };
// Reset the scroll container if it exists
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
scrollContainerRef.current.scrollLeft = 0;
}
};
}, []);
return ( return (
<div className="flex flex-col h-[calc(100vh-10rem)] overflow-hidden"> <div className="flex flex-col h-[calc(100vh-10rem)] overflow-hidden">
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">

View File

@@ -189,10 +189,6 @@ const ValidationTable = <T extends string>({
}: ValidationTableProps<T>) => { }: ValidationTableProps<T>) => {
const { translations } = useRsi<T>(); const { translations } = useRsi<T>();
// Debug logs
console.log('ValidationTable rowProductLines:', rowProductLines);
console.log('ValidationTable rowSublines:', rowSublines);
// Add state for copy down selection mode // Add state for copy down selection mode
const [isInCopyDownMode, setIsInCopyDownMode] = useState(false); const [isInCopyDownMode, setIsInCopyDownMode] = useState(false);
const [sourceRowIndex, setSourceRowIndex] = useState<number | null>(null); const [sourceRowIndex, setSourceRowIndex] = useState<number | null>(null);
@@ -308,10 +304,13 @@ const ValidationTable = <T extends string>({
const cache = new Map<string, readonly any[]>(); const cache = new Map<string, readonly any[]>();
fields.forEach((field) => { fields.forEach((field) => {
// Don't skip disabled fields // Get the field key
const fieldKey = String(field.key);
if (field.fieldType.type === 'select' || field.fieldType.type === 'multi-select') { // Handle all select and multi-select fields the same way
const fieldKey = String(field.key); if (field.fieldType &&
(typeof field.fieldType === 'object') &&
(field.fieldType.type === 'select' || field.fieldType.type === 'multi-select')) {
cache.set(fieldKey, (field.fieldType as any).options || []); cache.set(fieldKey, (field.fieldType as any).options || []);
} }
}); });
@@ -357,30 +356,35 @@ const ValidationTable = <T extends string>({
if (fieldKey === 'line' && rowId && rowProductLines[rowId]) { if (fieldKey === 'line' && rowId && rowProductLines[rowId]) {
options = rowProductLines[rowId]; options = rowProductLines[rowId];
console.log(`Setting line options for row ${rowId}:`, options);
} else if (fieldKey === 'subline' && rowId && rowSublines[rowId]) { } else if (fieldKey === 'subline' && rowId && rowSublines[rowId]) {
options = rowSublines[rowId]; options = rowSublines[rowId];
console.log(`Setting subline options for row ${rowId}:`, options);
} }
// Determine if this cell is in loading state // Determine if this cell is in loading state - use a clear consistent approach
let isLoading = validatingCells.has(`${row.index}-${field.key}`); 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;
}
// Add loading state for line/subline fields // Add loading state for line/subline fields
if (fieldKey === 'line' && rowId && isLoadingLines[rowId]) { else if (fieldKey === 'line' && rowId && isLoadingLines[rowId]) {
isLoading = true; isLoading = true;
console.log(`Line field for row ${rowId} is loading`); }
} else if (fieldKey === 'subline' && rowId && isLoadingSublines[rowId]) { else if (fieldKey === 'subline' && rowId && isLoadingSublines[rowId]) {
isLoading = true; isLoading = true;
console.log(`Subline field for row ${rowId} is loading`);
} }
// Get validation errors for this cell
const cellErrors = validationErrors.get(row.index)?.[fieldKey] || [];
return ( return (
<MemoizedCell <MemoizedCell
field={field as Field<string>} field={field as Field<string>}
value={row.original[field.key as keyof typeof row.original]} value={row.original[field.key as keyof typeof row.original]}
onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)} onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)}
errors={validationErrors.get(row.index)?.[fieldKey] || []} errors={cellErrors}
isValidating={isLoading} isValidating={isLoading}
fieldKey={fieldKey} fieldKey={fieldKey}
options={options} options={options}
@@ -494,10 +498,13 @@ const ValidationTable = <T extends string>({
</div> </div>
)} )}
<div className="relative"> <div className="relative">
{/* Custom Table Header - Always Visible */} {/* Custom Table Header - Always Visible with GPU acceleration */}
<div <div
className={`sticky top-0 z-20 bg-muted border-b shadow-sm`} className="sticky top-0 z-20 bg-muted border-b shadow-sm will-change-transform"
style={{ width: `${totalWidth}px` }} style={{
width: `${totalWidth}px`,
transform: 'translateZ(0)', // Force GPU acceleration
}}
> >
<div className="flex"> <div className="flex">
{table.getFlatHeaders().map((header) => { {table.getFlatHeaders().map((header) => {
@@ -521,49 +528,57 @@ const ValidationTable = <T extends string>({
</div> </div>
</div> </div>
{/* Table Body - Restore the original structure */} {/* Table Body - With optimized rendering */}
<Table style={{ width: `${totalWidth}px`, tableLayout: 'fixed', borderCollapse: 'separate', borderSpacing: 0, marginTop: '-1px' }}> <Table style={{
width: `${totalWidth}px`,
tableLayout: 'fixed',
borderCollapse: 'separate',
borderSpacing: 0,
marginTop: '-1px',
willChange: 'transform', // Help browser optimize
contain: 'content', // Contain paint operations
transform: 'translateZ(0)' // Force GPU acceleration
}}>
<TableBody> <TableBody>
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => {
<TableRow // Precompute validation error status for this row
key={row.id} const hasErrors = validationErrors.has(parseInt(row.id)) &&
className={cn( Object.keys(validationErrors.get(parseInt(row.id)) || {}).length > 0;
"hover:bg-muted/50",
row.getIsSelected() ? "bg-muted/50" : "", // Precompute copy down target status
validationErrors.get(parseInt(row.id)) && const isCopyDownTarget = isInCopyDownMode &&
Object.keys(validationErrors.get(parseInt(row.id)) || {}).length > 0 ? "bg-red-50/40" : "", sourceRowIndex !== null &&
// Add cursor-pointer class when in copy down mode for target rows parseInt(row.id) > sourceRowIndex;
isInCopyDownMode && sourceRowIndex !== null && parseInt(row.id) > sourceRowIndex ? "cursor-pointer copy-down-target-row" : ""
)} // Using CSS variables for better performance on hover/state changes
style={{ const rowStyle = {
// Force cursor pointer on all target rows cursor: isCopyDownTarget ? 'pointer' : undefined,
cursor: isInCopyDownMode && sourceRowIndex !== null && parseInt(row.id) > sourceRowIndex ? 'pointer' : undefined, position: 'relative' as const,
position: 'relative' // Ensure we can position the overlay willChange: isInCopyDownMode ? 'background-color' : 'auto',
}} contain: 'layout',
onMouseEnter={() => handleRowMouseEnter(parseInt(row.id))} transition: 'background-color 100ms ease-in-out'
> };
{row.getVisibleCells().map((cell: any) => {
const width = cell.column.getSize(); return (
return ( <TableRow
<TableCell key={row.id}
key={cell.id} className={cn(
style={{ "hover:bg-muted/50",
width: `${width}px`, row.getIsSelected() ? "bg-muted/50" : "",
minWidth: `${width}px`, hasErrors ? "bg-red-50/40" : "",
maxWidth: `${width}px`, isCopyDownTarget ? "cursor-pointer copy-down-target-row" : ""
boxSizing: 'border-box', )}
padding: '0', style={rowStyle}
// Force cursor pointer on all cells in target rows onMouseEnter={() => handleRowMouseEnter(parseInt(row.id))}
cursor: isInCopyDownMode && sourceRowIndex !== null && parseInt(row.id) > sourceRowIndex ? 'pointer' : undefined >
}} {row.getVisibleCells().map((cell: any) => (
className={isInCopyDownMode && sourceRowIndex !== null && parseInt(row.id) > sourceRowIndex ? "target-row-cell" : ""} <React.Fragment key={cell.id}>
>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell> </React.Fragment>
); ))}
})} </TableRow>
</TableRow> );
))} })}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

View File

@@ -107,23 +107,28 @@ const InputCell = <T extends string>({
// Handle blur event - use transition for non-critical updates // Handle blur event - use transition for non-critical updates
const handleBlur = useCallback(() => { 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(() => { startTransition(() => {
setIsEditing(false); setIsEditing(false);
// Format the value for storage (remove formatting like $ for price) // Format the value for storage (remove formatting like $ for price)
let processedValue = deferredEditValue.trim(); let processedValue = finalValue;
if (isPrice && processedValue) { if (isPrice && processedValue) {
needsProcessingRef.current = true; needsProcessingRef.current = true;
} }
// Update local display value immediately // Update local display value immediately to prevent UI flicker
setLocalDisplayValue(processedValue); setLocalDisplayValue(processedValue);
// Commit the change to parent component
onChange(processedValue); onChange(processedValue);
onEndEdit?.(); onEndEdit?.();
}); });
}, [deferredEditValue, onChange, onEndEdit, isPrice]); }, [editValue, onChange, onEndEdit, isPrice]);
// Handle direct input change - optimized to be synchronous for typing // Handle direct input change - optimized to be synchronous for typing
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {

View File

@@ -167,41 +167,59 @@ const MultiSelectCell = <T extends string>({
const commandListRef = useRef<HTMLDivElement>(null) const commandListRef = useRef<HTMLDivElement>(null)
// Add state for hover // Add state for hover
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
// Add ref to track if we need to sync internal state with external value
const shouldSyncWithExternalValue = useRef(true)
// Create a memoized Set for fast lookups of selected values // Create a memoized Set for fast lookups of selected values
const selectedValueSet = useMemo(() => new Set(internalValue), [internalValue]); const selectedValueSet = useMemo(() => new Set(internalValue), [internalValue]);
// Sync internalValue with external value when component mounts or value changes externally // Sync internalValue with external value when component mounts or value changes externally
// Modified to prevent infinite loop by checking if values are different before updating
useEffect(() => { useEffect(() => {
if (!open) { // Only sync if we should (not during internal edits) and if not open
// Ensure value is always an array if (shouldSyncWithExternalValue.current && !open) {
setInternalValue(Array.isArray(value) ? value : []) const externalValue = Array.isArray(value) ? value : [];
// Only update if values are actually different to prevent infinite loops
if (internalValue.length !== externalValue.length ||
!internalValue.every(v => externalValue.includes(v)) ||
!externalValue.every(v => internalValue.includes(v))) {
setInternalValue(externalValue);
}
} }
}, [value, open]) }, [value, open, internalValue]);
// Handle open state changes with improved responsiveness // Handle open state changes with improved responsiveness
const handleOpenChange = useCallback((newOpen: boolean) => { const handleOpenChange = useCallback((newOpen: boolean) => {
if (open && !newOpen) { if (open && !newOpen) {
// Prevent syncing with external value during our internal update
shouldSyncWithExternalValue.current = false;
// Only update parent state when dropdown closes // Only update parent state when dropdown closes
// Avoid expensive deep comparison if lengths are different // Make a defensive copy to avoid mutations
if (internalValue.length !== value.length || const valuesToCommit = [...internalValue];
internalValue.some((v, i) => v !== value[i])) {
onChange(internalValue); // Immediate UI update
} setOpen(false);
// Update parent with the value immediately
onChange(valuesToCommit);
if (onEndEdit) onEndEdit(); if (onEndEdit) onEndEdit();
// Allow syncing with external value again after a short delay
setTimeout(() => {
shouldSyncWithExternalValue.current = true;
}, 0);
} else if (newOpen && !open) { } else if (newOpen && !open) {
// Sync internal state with external state when opening // When opening the dropdown, sync with external value
setInternalValue(Array.isArray(value) ? value : []); const externalValue = Array.isArray(value) ? value : [];
setInternalValue(externalValue);
setSearchQuery(""); // Reset search query on open setSearchQuery(""); // Reset search query on open
setOpen(true);
if (onStartEdit) onStartEdit(); if (onStartEdit) onStartEdit();
} else if (!newOpen) { } else if (!newOpen) {
// Handle case when dropdown is already closed but handleOpenChange is called // Handle case when dropdown is already closed but handleOpenChange is called
// This ensures values are saved when clicking the chevron to close setOpen(false);
if (internalValue.length !== value.length ||
internalValue.some((v, i) => v !== value[i])) {
onChange(internalValue);
}
if (onEndEdit) onEndEdit();
} }
}, [open, internalValue, value, onChange, onStartEdit, onEndEdit]); }, [open, internalValue, value, onChange, onStartEdit, onEndEdit]);
@@ -302,13 +320,25 @@ const MultiSelectCell = <T extends string>({
// Update the handleSelect to operate on internalValue instead of directly calling onChange // Update the handleSelect to operate on internalValue instead of directly calling onChange
const handleSelect = useCallback((selectedValue: string) => { const handleSelect = useCallback((selectedValue: string) => {
// Prevent syncing with external value during our internal update
shouldSyncWithExternalValue.current = false;
setInternalValue(prev => { setInternalValue(prev => {
let newValue;
if (prev.includes(selectedValue)) { if (prev.includes(selectedValue)) {
return prev.filter(v => v !== selectedValue); // Remove the value
newValue = prev.filter(v => v !== selectedValue);
} else { } else {
return [...prev, selectedValue]; // Add the value - make a new array to avoid mutations
newValue = [...prev, selectedValue];
} }
return newValue;
}); });
// Allow syncing with external value again after a short delay
setTimeout(() => {
shouldSyncWithExternalValue.current = true;
}, 0);
}, []); }, []);
// Handle wheel scroll in dropdown // Handle wheel scroll in dropdown

View File

@@ -51,8 +51,6 @@ const SelectCell = <T extends string>({
// Add state for hover // Add state for hover
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
console.log(`SelectCell: field.key=${field.key}, disabled=${disabled}, options=`, options);
// Helper function to check if a class is present in the className string // Helper function to check if a class is present in the className string
const hasClass = (cls: string): boolean => { const hasClass = (cls: string): boolean => {
const classNames = className.split(' '); const classNames = className.split(' ');
@@ -68,7 +66,6 @@ const SelectCell = <T extends string>({
// Memoize options processing to avoid recalculation on every render // Memoize options processing to avoid recalculation on every render
const selectOptions = useMemo(() => { const selectOptions = useMemo(() => {
console.log(`Processing options for ${field.key}:`, options);
// Fast path check - if we have raw options, just use those // Fast path check - if we have raw options, just use those
if (options && options.length > 0) { if (options && options.length > 0) {
// Check if options already have the correct structure to avoid mapping // Check if options already have the correct structure to avoid mapping
@@ -126,8 +123,11 @@ const SelectCell = <T extends string>({
// Handle selection - UPDATE INTERNAL VALUE FIRST // Handle selection - UPDATE INTERNAL VALUE FIRST
const handleSelect = useCallback((selectedValue: string) => { const handleSelect = useCallback((selectedValue: string) => {
// Store the selected value to prevent it being lost in async operations
const valueToCommit = selectedValue;
// 1. Update internal value immediately to prevent UI flicker // 1. Update internal value immediately to prevent UI flicker
setInternalValue(selectedValue); setInternalValue(valueToCommit);
// 2. Close the dropdown immediately // 2. Close the dropdown immediately
setOpen(false); setOpen(false);
@@ -139,105 +139,44 @@ const SelectCell = <T extends string>({
// This prevents the parent component from re-rendering and causing dropdown to reopen // This prevents the parent component from re-rendering and causing dropdown to reopen
if (onEndEdit) onEndEdit(); if (onEndEdit) onEndEdit();
// 5. Call onChange in the next tick to avoid synchronous re-renders // 5. Call onChange synchronously to avoid race conditions with other cells
onChange(valueToCommit);
// 6. Clear processing state after a short delay
setTimeout(() => { setTimeout(() => {
onChange(selectedValue); setIsProcessing(false);
}, 0); }, 200);
}, [onChange, onEndEdit]); }, [onChange, onEndEdit]);
// If disabled, render a static view // If disabled, render a static view
if (disabled) { if (disabled) {
const displayText = displayValue; const displayText = displayValue;
// For debugging, let's render the Popover component even if disabled
// This will help us determine if the issue is with the disabled state
return ( return (
<Popover <div
open={open} className={cn(
onOpenChange={(isOpen) => { "w-full px-3 py-2 h-10 rounded-md text-sm flex items-center",
setOpen(isOpen); "border",
if (isOpen && onStartEdit) onStartEdit(); hasErrors ? "border-destructive" : "border-input",
}} className
> )}
<PopoverTrigger asChild> style={{
<Button backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
variant="outline" hasClass('!bg-blue-200') ? '#bfdbfe' :
role="combobox" hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
aria-expanded={open}
className={cn(
"w-full justify-between font-normal",
"border",
!internalValue && "text-muted-foreground",
isProcessing && "text-muted-foreground",
hasErrors ? "border-destructive" : "",
className
)}
style={{
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
hasClass('!bg-blue-200') ? '#bfdbfe' :
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
undefined,
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
hasClass('!border-blue-200') ? '#bfdbfe' :
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
undefined, undefined,
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined, borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined, hasClass('!border-blue-200') ? '#bfdbfe' :
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
}} undefined,
onClick={(e) => { borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
e.preventDefault(); cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
e.stopPropagation(); }}
setOpen(!open); onMouseEnter={() => setIsHovered(true)}
if (!open && onStartEdit) onStartEdit(); onMouseLeave={() => setIsHovered(false)}
}} >
onMouseEnter={() => setIsHovered(true)} {displayText || ""}
onMouseLeave={() => setIsHovered(false)} </div>
>
<span className={isProcessing ? "opacity-70" : ""}>
{displayValue}
</span>
<ChevronsUpDown className="mr-1.5 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0 w-[var(--radix-popover-trigger-width)]"
align="start"
sideOffset={4}
>
<Command shouldFilter={true}>
<CommandInput
placeholder="Search..."
className="h-9"
/>
<CommandList
ref={commandListRef}
onWheel={handleWheel}
className="max-h-[200px]"
>
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup>
{selectOptions.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(value) => handleSelect(value)}
className="flex w-full"
>
<Check
className={cn(
"mr-2 h-4 w-4 flex-shrink-0",
internalValue === option.value ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate w-full">{option.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
); );
} }

View File

@@ -39,24 +39,27 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
// Fetch product lines from API // Fetch product lines from API
const productLinesUrl = `/api/import/product-lines/${companyId}`; const productLinesUrl = `/api/import/product-lines/${companyId}`;
console.log(`Fetching from URL: ${productLinesUrl}`);
const response = await axios.get(productLinesUrl); const response = await axios.get(productLinesUrl);
if (response.status !== 200) {
throw new Error(`Failed to fetch product lines: ${response.status}`);
}
const productLines = response.data; const lines = response.data;
console.log(`Received ${productLines.length} product lines for company ${companyId}`); console.log(`Received ${lines.length} product lines for company ${companyId}`);
// Format the data properly for dropdown display
const formattedLines = lines.map((line: any) => ({
label: line.name || line.label || String(line.value || line.id),
value: String(line.value || line.id)
}));
// Store in company cache // Store in company cache
setCompanyLinesCache(prev => ({ ...prev, [companyId]: productLines })); setCompanyLinesCache(prev => ({ ...prev, [companyId]: formattedLines }));
// Store for this specific row // Store for this specific row
setRowProductLines(prev => ({ ...prev, [rowIndex]: productLines })); setRowProductLines(prev => ({ ...prev, [rowIndex]: formattedLines }));
return productLines; return formattedLines;
} catch (error) { } catch (error) {
console.error(`Error fetching product lines for company ${companyId}:`, error); console.error(`Error fetching product lines for company ${companyId}:`, error);
toast.error(`Failed to load product lines for company ${companyId}`);
// Set empty array for this company to prevent repeated failed requests // Set empty array for this company to prevent repeated failed requests
setCompanyLinesCache(prev => ({ ...prev, [companyId]: [] })); setCompanyLinesCache(prev => ({ ...prev, [companyId]: [] }));
@@ -92,22 +95,24 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
// Fetch sublines from API // Fetch sublines from API
const sublinesUrl = `/api/import/sublines/${lineId}`; const sublinesUrl = `/api/import/sublines/${lineId}`;
console.log(`Fetching from URL: ${sublinesUrl}`);
const response = await axios.get(sublinesUrl); const response = await axios.get(sublinesUrl);
if (response.status !== 200) {
throw new Error(`Failed to fetch sublines: ${response.status}`);
}
const sublines = response.data; const sublines = response.data;
console.log(`Received ${sublines.length} sublines for line ${lineId}`); console.log(`Received ${sublines.length} sublines for line ${lineId}`);
// Format the data properly for dropdown display
const formattedSublines = sublines.map((subline: any) => ({
label: subline.name || subline.label || String(subline.value || subline.id),
value: String(subline.value || subline.id)
}));
// Store in line cache // Store in line cache
setLineSublineCache(prev => ({ ...prev, [lineId]: sublines })); setLineSublineCache(prev => ({ ...prev, [lineId]: formattedSublines }));
// Store for this specific row // Store for this specific row
setRowSublines(prev => ({ ...prev, [rowIndex]: sublines })); setRowSublines(prev => ({ ...prev, [rowIndex]: formattedSublines }));
return sublines; return formattedSublines;
} catch (error) { } catch (error) {
console.error(`Error fetching sublines for line ${lineId}:`, error); console.error(`Error fetching sublines for line ${lineId}:`, error);
@@ -129,6 +134,25 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
// Skip if there's no data // Skip if there's no data
if (!data.length) return; if (!data.length) return;
// First check if we need to do anything at all
let needsFetching = false;
// Quick check for any rows that would need fetching
for (const row of data) {
const rowId = row.__index;
if (!rowId) continue;
if ((row.company && !rowProductLines[rowId]) || (row.line && !rowSublines[rowId])) {
needsFetching = true;
break;
}
}
// If nothing needs fetching, exit early
if (!needsFetching) {
return;
}
console.log("Starting to fetch product lines and sublines"); console.log("Starting to fetch product lines and sublines");
// Group rows by company and line to minimize API calls // Group rows by company and line to minimize API calls
@@ -160,6 +184,11 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
console.log(`Need to fetch product lines for ${companiesNeeded.size} companies and sublines for ${linesNeeded.size} lines`); console.log(`Need to fetch product lines for ${companiesNeeded.size} companies and sublines for ${linesNeeded.size} lines`);
// If nothing to fetch, exit early to prevent unnecessary processing
if (companiesNeeded.size === 0 && linesNeeded.size === 0) {
return;
}
// Create arrays to hold all fetch promises // Create arrays to hold all fetch promises
const fetchPromises: Promise<void>[] = []; const fetchPromises: Promise<void>[] = [];

View File

@@ -1,11 +1,11 @@
import { useState, useCallback, useRef, useEffect } from 'react' import { useState, useCallback, useRef, useEffect } from 'react'
import config from '@/config' import config from '@/config'
interface ValidationState { interface ValidationState {
validatingCells: Set<string>; // Using rowIndex-fieldKey as identifier validatingCells: Set<string>; // Using rowIndex-fieldKey as identifier
itemNumbers: Map<number, string>; // Using rowIndex as key itemNumbers: Map<number, string>; // Using rowIndex as key
validatingRows: Set<number>; // Rows currently being validated validatingRows: Set<number>; // Rows currently being validated
activeValidations: Set<string>; // Active validations
} }
export const useUpcValidation = ( export const useUpcValidation = (
@@ -16,19 +16,27 @@ export const useUpcValidation = (
const validationStateRef = useRef<ValidationState>({ const validationStateRef = useRef<ValidationState>({
validatingCells: new Set(), validatingCells: new Set(),
itemNumbers: new Map(), itemNumbers: new Map(),
validatingRows: new Set() validatingRows: new Set(),
activeValidations: new Set()
}); });
// Use state only for forcing re-renders of specific cells // Use state only for forcing re-renders of specific cells
const [validatingCellKeys, setValidatingCellKeys] = useState<Set<string>>(new Set()); const [, setValidatingCellKeys] = useState<Set<string>>(new Set());
const [itemNumberUpdates, setItemNumberUpdates] = useState<Map<number, string>>(new Map()); const [, setItemNumberUpdates] = useState<Map<number, string>>(new Map());
const [validatingRows, setValidatingRows] = useState<Set<number>>(new Set()); const [validatingRows, setValidatingRows] = useState<Set<number>>(new Set());
const [isValidatingUpc, setIsValidatingUpc] = useState(false); const [, setIsValidatingUpc] = useState(false);
// Cache for UPC validation results // Cache for UPC validation results
const processedUpcMapRef = useRef(new Map<string, string>()); const processedUpcMapRef = useRef(new Map<string, string>());
const initialUpcValidationDoneRef = useRef(false); const initialUpcValidationDoneRef = useRef(false);
// For batch validation
const validationQueueRef = useRef<Array<{rowIndex: number, supplierId: string, upcValue: string}>>([]);
const isProcessingBatchRef = useRef(false);
// For validation results
const [upcValidationResults] = useState<Map<number, { itemNumber: string }>>(new Map());
// Helper to create cell key // Helper to create cell key
const getCellKey = (rowIndex: number, fieldKey: string) => `${rowIndex}-${fieldKey}`; const getCellKey = (rowIndex: number, fieldKey: string) => `${rowIndex}-${fieldKey}`;
@@ -48,6 +56,7 @@ export const useUpcValidation = (
// Update item number // Update item number
const updateItemNumber = useCallback((rowIndex: number, itemNumber: string) => { const updateItemNumber = useCallback((rowIndex: number, itemNumber: string) => {
console.log(`Setting item number for row ${rowIndex} to ${itemNumber}`);
validationStateRef.current.itemNumbers.set(rowIndex, itemNumber); validationStateRef.current.itemNumbers.set(rowIndex, itemNumber);
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers)); setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
}, []); }, []);
@@ -56,6 +65,7 @@ export const useUpcValidation = (
const startValidatingRow = useCallback((rowIndex: number) => { const startValidatingRow = useCallback((rowIndex: number) => {
validationStateRef.current.validatingRows.add(rowIndex); validationStateRef.current.validatingRows.add(rowIndex);
setValidatingRows(new Set(validationStateRef.current.validatingRows)); setValidatingRows(new Set(validationStateRef.current.validatingRows));
setIsValidatingUpc(true);
}, []); }, []);
// Mark a row as no longer being validated // Mark a row as no longer being validated
@@ -83,114 +93,270 @@ export const useUpcValidation = (
const getItemNumber = useCallback((rowIndex: number): string | undefined => { const getItemNumber = useCallback((rowIndex: number): string | undefined => {
return validationStateRef.current.itemNumbers.get(rowIndex); return validationStateRef.current.itemNumbers.get(rowIndex);
}, []); }, []);
// Fetch product by UPC from API
const fetchProductByUpc = useCallback(async (supplierId: string, upcValue: string) => {
try {
console.log(`Fetching product for UPC ${upcValue} with supplier ${supplierId}`);
const response = await fetch(`${config.apiUrl}/import/check-upc-and-generate-sku?upc=${encodeURIComponent(upcValue)}&supplierId=${encodeURIComponent(supplierId)}`);
// Handle error responses
if (response.status === 409) {
console.log(`UPC ${upcValue} already exists`);
return { error: true, message: 'UPC already exists' };
}
if (!response.ok) {
console.error(`API error: ${response.status}`);
return { error: true, message: `API error (${response.status})` };
}
// Process successful response
const data = await response.json();
if (!data.success) {
return { error: true, message: data.message || 'Unknown error' };
}
return {
error: false,
data: {
itemNumber: data.itemNumber || '',
...data
}
};
} catch (error) {
console.error('Network error:', error);
return { error: true, message: 'Network error' };
}
}, []);
// Apply all pending updates to the data state // Validate a UPC for a row - returns a promise that resolves when complete
const applyItemNumbersToData = useCallback(() => { const validateUpc = useCallback(async (rowIndex: number, supplierId: string, upcValue: string) => {
if (validationStateRef.current.itemNumbers.size === 0) return; // Clear any previous validation keys for this row to avoid cancellations
const previousKeys = Array.from(validationStateRef.current.activeValidations).filter(key =>
key.startsWith(`${rowIndex}-`)
);
previousKeys.forEach(key => validationStateRef.current.activeValidations.delete(key));
setData((prevData: any[]) => { // Start validation - track this with the ref to avoid race conditions
startValidatingRow(rowIndex);
startValidatingCell(rowIndex, 'item_number');
console.log(`Validating UPC: rowIndex=${rowIndex}, supplierId=${supplierId}, upc=${upcValue}`);
try {
// Create a unique key for this validation to track it
const validationKey = `${rowIndex}-${supplierId}-${upcValue}`;
validationStateRef.current.activeValidations.add(validationKey);
// IMPORTANT: First update the data with the new UPC value to prevent UI flicker
// This ensures the UPC field keeps showing the new value while validation runs
setData(prevData => {
const newData = [...prevData];
if (newData[rowIndex]) {
newData[rowIndex] = {
...newData[rowIndex],
upc: upcValue
};
}
return newData;
});
// Fetch the product by UPC
const product = await fetchProductByUpc(supplierId, upcValue);
// Check if this validation is still relevant (hasn't been superseded by another)
if (!validationStateRef.current.activeValidations.has(validationKey)) {
console.log(`Validation ${validationKey} was cancelled`);
return { success: false };
}
// Extract the item number from the API response - check for !error since API returns { error: boolean, data: any }
if (product && !product.error && product.data?.itemNumber) {
// Store this validation result
updateItemNumber(rowIndex, product.data.itemNumber);
return {
success: true,
itemNumber: product.data.itemNumber
};
} else {
// No item number found but validation was still attempted
console.log(`No item number found for UPC ${upcValue}`);
// Clear any existing item number to show validation was attempted and failed
if (validationStateRef.current.itemNumbers.has(rowIndex)) {
validationStateRef.current.itemNumbers.delete(rowIndex);
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
}
return { success: false };
}
} catch (error) {
console.error('Error validating UPC:', error);
return { success: false };
} finally {
// End validation
stopValidatingRow(rowIndex);
stopValidatingCell(rowIndex, 'item_number');
}
}, [fetchProductByUpc, updateItemNumber, startValidatingCell, stopValidatingCell, startValidatingRow, stopValidatingRow, setData]);
// Apply item numbers to data
const applyItemNumbersToData = useCallback((onApplied?: (updatedRowIds: number[]) => void) => {
// Create a copy of the current item numbers map to avoid race conditions
const currentItemNumbers = new Map(validationStateRef.current.itemNumbers);
// Only apply if we have any item numbers
if (currentItemNumbers.size === 0) return;
// Track updated row indices to pass to callback
const updatedRowIndices: number[] = [];
// Log for debugging
console.log(`Applying ${currentItemNumbers.size} item numbers to data`);
setData(prevData => {
// Create a new copy of the data
const newData = [...prevData]; const newData = [...prevData];
// Apply all item numbers without changing other data // Update each row with its item number without affecting other fields
Array.from(validationStateRef.current.itemNumbers.entries()).forEach(([index, itemNumber]) => { currentItemNumbers.forEach((itemNumber, rowIndex) => {
if (index >= 0 && index < newData.length) { if (rowIndex < newData.length) {
// Only update the item_number field and leave everything else unchanged console.log(`Setting item_number for row ${rowIndex} to ${itemNumber}`);
newData[index] = {
...newData[index], // Only update the item_number field, leaving other fields unchanged
newData[rowIndex] = {
...newData[rowIndex],
item_number: itemNumber item_number: itemNumber
}; };
// Track which rows were updated
updatedRowIndices.push(rowIndex);
} }
}); });
return newData; return newData;
}); });
// Clear the item numbers state after applying // Call the callback if provided, after state updates are processed
validationStateRef.current.itemNumbers.clear(); if (onApplied && updatedRowIndices.length > 0) {
setItemNumberUpdates(new Map()); // Use setTimeout to ensure this happens after the state update
setTimeout(() => {
onApplied(updatedRowIndices);
}, 100); // Use 100ms to ensure the data update is fully processed
}
}, [setData]); }, [setData]);
// Validate a UPC value // Process validation queue in batches - faster processing with smaller batches
const validateUpc = useCallback(async ( const processBatchValidation = useCallback(async () => {
rowIndex: number, if (isProcessingBatchRef.current) return;
supplierId: string, if (validationQueueRef.current.length === 0) return;
upcValue: string
): Promise<{success: boolean, itemNumber?: string}> => { console.log(`Processing validation batch with ${validationQueueRef.current.length} items`);
isProcessingBatchRef.current = true;
// Process in smaller batches for better UI responsiveness
const BATCH_SIZE = 5;
const queue = [...validationQueueRef.current];
validationQueueRef.current = [];
// Track if any updates were made
let updatesApplied = false;
// Track updated row indices
const updatedRows: number[] = [];
try { try {
// Skip if either value is missing // Process in small batches
if (!supplierId || !upcValue) { for (let i = 0; i < queue.length; i += BATCH_SIZE) {
return { success: false }; const batch = queue.slice(i, i + BATCH_SIZE);
}
// Add logging to help debug
console.log(`Validating UPC for row ${rowIndex}. Supplier ID: ${supplierId}, UPC: ${upcValue}`);
// Start validating both UPC and item_number cells
startValidatingCell(rowIndex, 'upc');
startValidatingCell(rowIndex, 'item_number');
// Also mark the row as being validated
startValidatingRow(rowIndex);
// Check if we've already validated this UPC/supplier combination
const cacheKey = `${supplierId}-${upcValue}`;
if (processedUpcMapRef.current.has(cacheKey)) {
const cachedItemNumber = processedUpcMapRef.current.get(cacheKey);
if (cachedItemNumber) { // Process batch in parallel
// Use cached item number const results = await Promise.all(batch.map(async ({ rowIndex, supplierId, upcValue }) => {
updateItemNumber(rowIndex, cachedItemNumber); try {
return { success: true, itemNumber: cachedItemNumber }; // Skip if already validated
const cacheKey = `${supplierId}-${upcValue}`;
if (processedUpcMapRef.current.has(cacheKey)) {
const cachedItemNumber = processedUpcMapRef.current.get(cacheKey);
if (cachedItemNumber) {
console.log(`Using cached item number for row ${rowIndex}: ${cachedItemNumber}`);
updateItemNumber(rowIndex, cachedItemNumber);
updatesApplied = true;
updatedRows.push(rowIndex);
return true;
}
return false;
}
// Fetch from API
const result = await fetchProductByUpc(supplierId, upcValue);
if (!result.error && result.data?.itemNumber) {
const itemNumber = result.data.itemNumber;
// Store in cache
processedUpcMapRef.current.set(cacheKey, itemNumber);
// Update item number
updateItemNumber(rowIndex, itemNumber);
updatesApplied = true;
updatedRows.push(rowIndex);
console.log(`Set item number for row ${rowIndex} to ${itemNumber}`);
return true;
}
return false;
} catch (error) {
console.error(`Error processing row ${rowIndex}:`, error);
return false;
} finally {
// Clear validation state
stopValidatingRow(rowIndex);
}
}));
// If any updates were applied in this batch, update the data
if (results.some(Boolean) && updatesApplied) {
applyItemNumbersToData(updatedRowIds => {
console.log(`Processed batch UPC validation for rows: ${updatedRowIds.join(', ')}`);
});
updatesApplied = false;
updatedRows.length = 0; // Clear the array
} }
return { success: false }; // Small delay between batches to allow UI to update
} if (i + BATCH_SIZE < queue.length) {
await new Promise(resolve => setTimeout(resolve, 10));
// Make API call to validate UPC
const response = await fetch(`${config.apiUrl}/import/check-upc-and-generate-sku?upc=${encodeURIComponent(upcValue)}&supplierId=${encodeURIComponent(supplierId)}`);
// Process the response
if (response.status === 409) {
// UPC already exists - show validation error
return { success: false };
} else if (response.ok) {
// Successful validation - update item number
const responseData = await response.json();
if (responseData.success && responseData.itemNumber) {
// Store in cache
processedUpcMapRef.current.set(cacheKey, responseData.itemNumber);
// Update the item numbers state
updateItemNumber(rowIndex, responseData.itemNumber);
return { success: true, itemNumber: responseData.itemNumber };
} }
} }
return { success: false };
} catch (error) { } catch (error) {
console.error(`Error validating UPC for row ${rowIndex}:`, error); console.error('Error in batch processing:', error);
return { success: false };
} finally { } finally {
// Clear validation state isProcessingBatchRef.current = false;
stopValidatingCell(rowIndex, 'upc');
stopValidatingCell(rowIndex, 'item_number'); // Process any new items
stopValidatingRow(rowIndex); if (validationQueueRef.current.length > 0) {
setTimeout(processBatchValidation, 0);
}
} }
}, [startValidatingCell, stopValidatingCell, updateItemNumber, startValidatingRow, stopValidatingRow]); }, [fetchProductByUpc, updateItemNumber, stopValidatingRow, applyItemNumbersToData]);
// For immediate processing
// Batch validate all UPCs in the data // Batch validate all UPCs in the data
const validateAllUPCs = useCallback(async () => { const validateAllUPCs = useCallback(async () => {
// Skip if we've already done the initial validation // Skip if we've already done the initial validation
if (initialUpcValidationDoneRef.current) { if (initialUpcValidationDoneRef.current) {
console.log('Initial UPC validation already done, skipping');
return; return;
} }
// Mark that we've done the initial validation // Mark that we've started the initial validation
initialUpcValidationDoneRef.current = true; initialUpcValidationDoneRef.current = true;
console.log('Starting UPC validation...'); console.log('Starting initial UPC validation...');
// Set validation state // Set validation state
setIsValidatingUpc(true); setIsValidatingUpc(true);
@@ -206,7 +372,7 @@ export const useUpcValidation = (
}); });
const totalRows = rowsToValidate.length; const totalRows = rowsToValidate.length;
console.log(`Found ${totalRows} rows with both supplier and UPC`); console.log(`Found ${totalRows} rows with both supplier and UPC for initial validation`);
if (totalRows === 0) { if (totalRows === 0) {
setIsValidatingUpc(false); setIsValidatingUpc(false);
@@ -219,37 +385,102 @@ export const useUpcValidation = (
setValidatingRows(newValidatingRows); setValidatingRows(newValidatingRows);
try { try {
// Process all rows in parallel // Process rows in batches for better UX
await Promise.all( const BATCH_SIZE = 100;
rowsToValidate.map(async ({ row, index }) => { const batches = [];
try {
const rowAny = row as Record<string, any>; // Split rows into batches
const supplierId = rowAny.supplier.toString(); for (let i = 0; i < rowsToValidate.length; i += BATCH_SIZE) {
const upcValue = (rowAny.upc || rowAny.barcode).toString(); batches.push(rowsToValidate.slice(i, i + BATCH_SIZE));
}
// Validate the UPC
await validateUpc(index, supplierId, upcValue); console.log(`Processing ${batches.length} batches for ${totalRows} rows`);
// Remove this row from the validating set (handled in validateUpc) // Process each batch sequentially
} catch (error) { for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) {
console.error(`Error processing row ${index}:`, error); const batch = batches[batchIndex];
stopValidatingRow(index); console.log(`Processing batch ${batchIndex + 1}/${batches.length} with ${batch.length} rows`);
}
}) // Track updated rows in this batch
); const batchUpdatedRows: number[] = [];
// Process all rows in current batch in parallel
await Promise.all(
batch.map(async ({ row, index }) => {
try {
const rowAny = row as Record<string, any>;
const supplierId = rowAny.supplier.toString();
const upcValue = (rowAny.upc || rowAny.barcode).toString();
console.log(`Validating UPC in initial batch: row=${index}, supplier=${supplierId}, upc=${upcValue}`);
// Mark the item_number cell as validating
startValidatingCell(index, 'item_number');
// Validate the UPC directly (don't use validateUpc to avoid duplicate UI updates)
const cacheKey = `${supplierId}-${upcValue}`;
// Check cache first
if (processedUpcMapRef.current.has(cacheKey)) {
const cachedItemNumber = processedUpcMapRef.current.get(cacheKey);
if (cachedItemNumber) {
console.log(`Using cached item number for row ${index}: ${cachedItemNumber}`);
updateItemNumber(index, cachedItemNumber);
batchUpdatedRows.push(index);
return;
}
}
// Make API call
const result = await fetchProductByUpc(supplierId, upcValue);
if (!result.error && result.data?.itemNumber) {
const itemNumber = result.data.itemNumber;
console.log(`Got item number from API for row ${index}: ${itemNumber}`);
// Cache the result
processedUpcMapRef.current.set(cacheKey, itemNumber);
// Update item number
updateItemNumber(index, itemNumber);
batchUpdatedRows.push(index);
} else {
console.warn(`No item number found for row ${index} UPC ${upcValue}`);
}
} catch (error) {
console.error(`Error validating row ${index}:`, error);
} finally {
// Clear validation state
stopValidatingCell(index, 'item_number');
stopValidatingRow(index);
}
})
);
// Apply updates for this batch
if (validationStateRef.current.itemNumbers.size > 0) {
console.log(`Applying item numbers after batch ${batchIndex + 1}`);
applyItemNumbersToData(updatedRowIds => {
console.log(`Processed initial UPC validation batch ${batchIndex + 1} for rows: ${updatedRowIds.join(', ')}`);
});
}
// Small delay between batches to update UI
if (batchIndex < batches.length - 1) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
} catch (error) { } catch (error) {
console.error('Error in batch validation:', error); console.error('Error in batch validation:', error);
} finally { } finally {
// Reset validation state // Make sure all validation states are cleared
setIsValidatingUpc(false);
validationStateRef.current.validatingRows.clear(); validationStateRef.current.validatingRows.clear();
setValidatingRows(new Set()); setValidatingRows(new Set());
console.log('Completed UPC validation'); setIsValidatingUpc(false);
// Apply item numbers to data console.log('Completed initial UPC validation');
applyItemNumbersToData();
} }
}, [data, validateUpc, stopValidatingRow, applyItemNumbersToData]); }, [data, fetchProductByUpc, updateItemNumber, startValidatingCell, stopValidatingCell, stopValidatingRow, applyItemNumbersToData]);
// Run initial UPC validation when data changes // Run initial UPC validation when data changes
useEffect(() => { useEffect(() => {
@@ -259,43 +490,28 @@ export const useUpcValidation = (
// Run validation // Run validation
validateAllUPCs(); validateAllUPCs();
}, [data, validateAllUPCs]); }, [data, validateAllUPCs]);
// Apply item numbers when they change // Return public API
useEffect(() => {
// Apply item numbers if there are any
if (validationStateRef.current.itemNumbers.size > 0) {
applyItemNumbersToData();
}
}, [itemNumberUpdates, applyItemNumbersToData]);
// Reset validation state when hook is unmounted
useEffect(() => {
return () => {
initialUpcValidationDoneRef.current = false;
processedUpcMapRef.current.clear();
validationStateRef.current.validatingCells.clear();
validationStateRef.current.itemNumbers.clear();
validationStateRef.current.validatingRows.clear();
};
}, []);
return { return {
// Validation methods
validateUpc, validateUpc,
validateAllUPCs, validateAllUPCs,
// Cell state
isValidatingCell, isValidatingCell,
isRowValidatingUpc, isRowValidatingUpc,
isValidatingUpc,
// Row state
validatingRows: validatingRows, // Expose as a Set to components
// Item number management
getItemNumber, getItemNumber,
applyItemNumbersToData, applyItemNumbersToData,
itemNumbers: itemNumberUpdates,
validatingCells: validatingCellKeys, // Results
validatingRows, upcValidationResults,
resetInitialValidation: () => {
initialUpcValidationDoneRef.current = false; // Initialization state
}, initialValidationDone: initialUpcValidationDoneRef.current
// Export the ref for direct access };
get initialValidationDone() { };
return initialUpcValidationDoneRef.current;
}
}
}

View File

@@ -6,7 +6,7 @@ import { RowData } from './useValidationState'
// Define InfoWithSource to match the expected structure // Define InfoWithSource to match the expected structure
// Make sure source is required (not optional) // Make sure source is required (not optional)
interface InfoWithSource { export interface InfoWithSource {
message: string; message: string;
level: 'info' | 'warning' | 'error'; level: 'info' | 'warning' | 'error';
source: ErrorSources; source: ErrorSources;
@@ -21,6 +21,40 @@ const isEmpty = (value: any): boolean =>
(Array.isArray(value) && value.length === 0) || (Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0); (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0);
// Cache validation results to avoid running expensive validations repeatedly
const validationResultCache = new Map<string, ValidationError[]>();
// Add debounce to prevent rapid successive validations
let validateDataTimeoutId: ReturnType<typeof setTimeout> | null = null;
// Add a function to clear cache for a specific field value
export const clearValidationCacheForField = (fieldKey: string, value: any) => {
// Create a pattern to match cache keys for this field
const pattern = new RegExp(`^${fieldKey}-`);
// Find and clear matching cache entries
validationResultCache.forEach((_, key) => {
if (pattern.test(key)) {
validationResultCache.delete(key);
}
});
};
// Add a special function to clear all uniqueness validation caches
export const clearAllUniquenessCaches = () => {
// Clear cache for common unique fields
['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'].forEach(fieldKey => {
clearValidationCacheForField(fieldKey, null);
});
// Also clear any cache entries that might involve uniqueness validation
validationResultCache.forEach((_, key) => {
if (key.includes('unique')) {
validationResultCache.delete(key);
}
});
};
export const useValidation = <T extends string>( export const useValidation = <T extends string>(
fields: Fields<T>, fields: Fields<T>,
rowHook?: RowHook<T>, rowHook?: RowHook<T>,
@@ -35,6 +69,14 @@ export const useValidation = <T extends string>(
if (!field.validations) return errors if (!field.validations) return errors
// Create a cache key using field key, value, and validation rules
const cacheKey = `${field.key}-${String(value)}-${JSON.stringify(field.validations)}`;
// Check cache first to avoid redundant validation
if (validationResultCache.has(cacheKey)) {
return validationResultCache.get(cacheKey) || [];
}
field.validations.forEach(validation => { field.validations.forEach(validation => {
switch (validation.rule) { switch (validation.rule) {
case 'required': case 'required':
@@ -71,6 +113,9 @@ export const useValidation = <T extends string>(
} }
}) })
// Store results in cache to speed up future validations
validationResultCache.set(cacheKey, errors);
return errors return errors
}, []) }, [])
@@ -223,83 +268,256 @@ export const useValidation = <T extends string>(
return uniqueErrors; return uniqueErrors;
}, [fields]); }, [fields]);
// Run complete validation // Additional function to explicitly validate uniqueness for specified fields
const validateData = useCallback(async (data: RowData<T>[]) => { const validateUniqueField = useCallback((data: RowData<T>[], fieldKey: string) => {
// Step 1: Run field and row validation for each row // Field keys that need special handling for uniqueness
const uniquenessFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
// Step 2: Run unique validations // If the field doesn't need uniqueness validation, return empty errors
const uniqueValidations = validateUnique(data); if (!uniquenessFields.includes(fieldKey)) {
const field = fields.find(f => String(f.key) === fieldKey);
if (!field || !field.validations?.some(v => v.rule === 'unique')) {
return new Map<number, Record<string, InfoWithSource>>();
}
}
// Step 3: Run table hook // Create map to track errors
const uniqueErrors = new Map<number, Record<string, InfoWithSource>>();
// Create a map to store all validation errors // Find the field definition
const validationErrors = new Map<number, Record<string, InfoWithSource>>(); const field = fields.find(f => String(f.key) === fieldKey);
if (!field) return uniqueErrors;
// Merge all validation results // Get validation properties
data.forEach((row, index) => { const validation = field.validations?.find(v => v.rule === 'unique');
// Collect errors from all validation sources const allowEmpty = validation?.allowEmpty ?? false;
const rowErrors: Record<string, InfoWithSource> = {}; const errorMessage = validation?.errorMessage || `${field.label} must be unique`;
const level = validation?.level || 'error';
// Track values for uniqueness check
const valueMap = new Map<string, number[]>();
// Build value map
data.forEach((row, rowIndex) => {
const value = String(row[fieldKey as keyof typeof row] || '');
// Add field-level errors (we need to extract these from the validation process) // Skip empty values if allowed
fields.forEach(field => { if (allowEmpty && isEmpty(value)) {
const value = row[String(field.key) as keyof typeof row]; return;
const errors = validateField(value, field as Field<T>);
if (errors.length > 0) {
rowErrors[String(field.key)] = {
message: errors[0].message,
level: errors[0].level,
source: ErrorSources.Row,
type: errors[0].type
};
}
});
// Add unique validation errors
if (uniqueValidations.has(index)) {
Object.entries(uniqueValidations.get(index) || {}).forEach(([key, error]) => {
rowErrors[key] = error;
});
} }
// Filter out "required" errors for fields that have values if (!valueMap.has(value)) {
const filteredErrors: Record<string, InfoWithSource> = {}; valueMap.set(value, [rowIndex]);
} else {
Object.entries(rowErrors).forEach(([key, error]) => { valueMap.get(value)?.push(rowIndex);
const fieldValue = row[key as keyof typeof row];
// If the field has a value and the error is of type Required, skip it
if (!isEmpty(fieldValue) &&
error &&
typeof error === 'object' &&
'type' in error &&
error.type === ErrorType.Required) {
return;
}
filteredErrors[key] = error;
});
// Only add to the map if there are errors
if (Object.keys(filteredErrors).length > 0) {
validationErrors.set(index, filteredErrors);
} }
}); });
// Add errors for duplicate values
valueMap.forEach((rowIndexes, value) => {
if (rowIndexes.length > 1) {
// Skip empty values
if (!value || value.trim() === '') return;
// Add error to all duplicate rows
rowIndexes.forEach(rowIndex => {
// Create errors object if needed
if (!uniqueErrors.has(rowIndex)) {
uniqueErrors.set(rowIndex, {});
}
// Add error for this field
uniqueErrors.get(rowIndex)![fieldKey] = {
message: errorMessage,
level: level as 'info' | 'warning' | 'error',
source: ErrorSources.Table,
type: ErrorType.Unique
};
});
}
});
return uniqueErrors;
}, [fields]);
// Run complete validation
const validateData = useCallback(async (data: RowData<T>[], fieldToUpdate?: { rowIndex: number, fieldKey: string }) => {
const validationErrors = new Map<number, Record<string, InfoWithSource>>();
// If we're updating a specific field, only validate that field for that row
if (fieldToUpdate) {
const { rowIndex, fieldKey } = fieldToUpdate;
// Special handling for fields that often update item_number
const triggersItemNumberValidation = fieldKey === 'upc' || fieldKey === 'barcode' || fieldKey === 'supplier';
// If updating a uniqueness field or field that affects item_number, clear ALL related validation caches
const isUniqueField = fieldKey === 'upc' || fieldKey === 'item_number' ||
fieldKey === 'supplier_no' || fieldKey === 'notions_no' ||
fieldKey === 'name' || triggersItemNumberValidation;
// Force cache clearing for uniqueness-validated fields to ensure fresh validation
if (isUniqueField) {
console.log(`Clearing validation cache for uniqueness field: ${fieldKey}`);
clearValidationCacheForField(fieldKey, null);
// If a field that might affect item_number, also clear item_number cache
if (triggersItemNumberValidation) {
console.log('Also clearing item_number validation cache');
clearValidationCacheForField('item_number', null);
}
}
if (rowIndex >= 0 && rowIndex < data.length) {
const row = data[rowIndex];
// Find the field definition
const field = fields.find(f => String(f.key) === fieldKey);
if (field) {
// Validate just this field for this row
const value = row[fieldKey as keyof typeof row];
const errors = validateField(value, field as Field<T>);
if (errors.length > 0) {
// Store the validation error
validationErrors.set(rowIndex, {
[fieldKey]: {
message: errors[0].message,
level: errors[0].level as 'info' | 'warning' | 'error',
source: ErrorSources.Row,
type: errors[0].type
}
});
}
// Check if the field requires uniqueness validation or if it's item_number after UPC/Supplier change
const needsUniquenessCheck = isUniqueField ||
field.validations?.some(v => v.rule === 'unique');
if (needsUniquenessCheck) {
console.log(`Running immediate uniqueness validation for field ${fieldKey}`);
// For item_number updated via UPC validation, or direct UPC update, check both fields
if (fieldKey === 'item_number' || fieldKey === 'upc' || fieldKey === 'barcode') {
// Validate both item_number and UPC/barcode fields for uniqueness
const itemNumberUniqueErrors = validateUniqueField(data, 'item_number');
const upcUniqueErrors = validateUniqueField(data, fieldKey === 'item_number' ? 'upc' : fieldKey);
// Combine the errors
itemNumberUniqueErrors.forEach((errors, rowIdx) => {
if (!validationErrors.has(rowIdx)) {
validationErrors.set(rowIdx, {});
}
Object.assign(validationErrors.get(rowIdx)!, errors);
});
upcUniqueErrors.forEach((errors, rowIdx) => {
if (!validationErrors.has(rowIdx)) {
validationErrors.set(rowIdx, {});
}
Object.assign(validationErrors.get(rowIdx)!, errors);
});
} else {
// Normal uniqueness validation for other fields
const uniqueErrors = validateUniqueField(data, fieldKey);
// Add unique errors to validation errors
uniqueErrors.forEach((errors, rowIdx) => {
if (!validationErrors.has(rowIdx)) {
validationErrors.set(rowIdx, {});
}
Object.assign(validationErrors.get(rowIdx)!, errors);
});
}
}
}
}
} else {
// Full validation - all fields for all rows
console.log('Running full validation for all fields and rows');
// Clear validation cache for full validation
validationResultCache.clear();
// Process each row for field-level validations
for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
const row = data[rowIndex];
let rowErrors: Record<string, InfoWithSource> = {};
// Validate all fields for this row
fields.forEach(field => {
const fieldKey = String(field.key);
const value = row[fieldKey as keyof typeof row];
const errors = validateField(value, field as Field<T>);
if (errors.length > 0) {
rowErrors[fieldKey] = {
message: errors[0].message,
level: errors[0].level as 'info' | 'warning' | 'error',
source: ErrorSources.Row,
type: errors[0].type
};
}
});
// Add row to validationErrors if it has any errors
if (Object.keys(rowErrors).length > 0) {
validationErrors.set(rowIndex, rowErrors);
}
}
// Get fields requiring uniqueness validation
const uniqueFields = fields.filter(field =>
field.validations?.some(v => v.rule === 'unique')
);
// Also add standard unique fields that might not be explicitly marked as unique
const standardUniqueFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
// Combine all fields that need uniqueness validation
const allUniqueFieldKeys = new Set([
...uniqueFields.map(field => String(field.key)),
...standardUniqueFields
]);
// Log uniqueness validation fields
console.log('Validating unique fields:', Array.from(allUniqueFieldKeys));
// Run uniqueness validation for each unique field
allUniqueFieldKeys.forEach(fieldKey => {
// Check if this field exists in the data
const hasField = data.some(row => fieldKey in row);
if (!hasField) return;
const uniqueErrors = validateUniqueField(data, fieldKey);
// Add unique errors to validation errors
uniqueErrors.forEach((errors, rowIdx) => {
if (!validationErrors.has(rowIdx)) {
validationErrors.set(rowIdx, {});
}
Object.assign(validationErrors.get(rowIdx)!, errors);
});
});
console.log('Uniqueness validation complete');
}
return { return {
data: data.map((row) => { data,
// Return the original data without __errors
return { ...row };
}),
validationErrors validationErrors
}; };
}, [validateRow, validateUnique, validateTable, fields, validateField]); }, [fields, validateField, validateUniqueField]);
return { return {
validateData, validateData,
validateField, validateField,
validateRow, validateRow,
validateTable, validateTable,
validateUnique validateUnique,
validateUniqueField,
clearValidationCacheForField,
clearAllUniquenessCaches
} }
} }