Product import speed/responsiveness fixes, particularly around validation

This commit is contained in:
2025-09-06 15:08:53 -04:00
parent 4dfe85231a
commit 5e2ee73e2d
8 changed files with 199 additions and 114 deletions

View File

@@ -20,6 +20,8 @@ interface UpcValidationTableAdapterProps<T extends string> {
copyDown: (rowIndex: number, fieldKey: string, endRowIndex?: number) => void copyDown: (rowIndex: number, fieldKey: string, endRowIndex?: number) => void
validatingCells: Set<string> validatingCells: Set<string>
isLoadingTemplates: boolean isLoadingTemplates: boolean
editingCells: Set<string>
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
rowProductLines: Record<string, any[]> rowProductLines: Record<string, any[]>
rowSublines: Record<string, any[]> rowSublines: Record<string, any[]>
isLoadingLines: Record<string, boolean> isLoadingLines: Record<string, boolean>
@@ -53,6 +55,8 @@ function UpcValidationTableAdapter<T extends string>({
copyDown, copyDown,
validatingCells: externalValidatingCells, validatingCells: externalValidatingCells,
isLoadingTemplates, isLoadingTemplates,
editingCells,
setEditingCells,
rowProductLines, rowProductLines,
rowSublines, rowSublines,
isLoadingLines, isLoadingLines,
@@ -86,11 +90,7 @@ function UpcValidationTableAdapter<T extends string>({
// First add from itemNumbers directly - this is the source of truth for template applications // First add from itemNumbers directly - this is the source of truth for template applications
if (itemNumbers) { if (itemNumbers) {
// Log all numbers for debugging
console.log(`[ADAPTER-DEBUG] Received itemNumbers map with ${itemNumbers.size} entries`);
itemNumbers.forEach((itemNumber, rowIndex) => { itemNumbers.forEach((itemNumber, rowIndex) => {
console.log(`[ADAPTER-DEBUG] Adding item number for row ${rowIndex} from itemNumbers: ${itemNumber}`);
result.set(rowIndex, itemNumber); result.set(rowIndex, itemNumber);
}); });
} }
@@ -100,14 +100,12 @@ function UpcValidationTableAdapter<T extends string>({
// Check if upcValidation has an item number for this row // Check if upcValidation has an item number for this row
const itemNumber = upcValidation.getItemNumber(index); const itemNumber = upcValidation.getItemNumber(index);
if (itemNumber) { if (itemNumber) {
console.log(`[ADAPTER-DEBUG] Adding item number for row ${index} from upcValidation: ${itemNumber}`);
result.set(index, itemNumber); result.set(index, itemNumber);
} }
// Also check if it's directly in the data // Also check if it's directly in the data
const dataItemNumber = data[index].item_number; const dataItemNumber = data[index].item_number;
if (dataItemNumber && !result.has(index)) { if (dataItemNumber && !result.has(index)) {
console.log(`[ADAPTER-DEBUG] Adding item number for row ${index} from data: ${dataItemNumber}`);
result.set(index, dataItemNumber); result.set(index, dataItemNumber);
} }
}); });
@@ -151,6 +149,8 @@ function UpcValidationTableAdapter<T extends string>({
rowSublines={rowSublines} rowSublines={rowSublines}
isLoadingLines={isLoadingLines} isLoadingLines={isLoadingLines}
isLoadingSublines={isLoadingSublines} isLoadingSublines={isLoadingSublines}
editingCells={editingCells}
setEditingCells={setEditingCells}
/> />
) )
} }

View File

@@ -78,7 +78,9 @@ const BaseCellContent = React.memo(({
hasErrors, hasErrors,
options = [], options = [],
className = '', className = '',
fieldKey = '' fieldKey = '',
onStartEdit,
onEndEdit
}: { }: {
field: Field<string>; field: Field<string>;
value: any; value: any;
@@ -87,6 +89,8 @@ const BaseCellContent = React.memo(({
options?: readonly any[]; options?: readonly any[];
className?: string; className?: string;
fieldKey?: string; fieldKey?: string;
onStartEdit?: () => void;
onEndEdit?: () => void;
}) => { }) => {
// Get field type information // Get field type information
const fieldType = fieldKey === 'line' || fieldKey === 'subline' const fieldType = fieldKey === 'line' || fieldKey === 'subline'
@@ -113,6 +117,8 @@ const BaseCellContent = React.memo(({
field={{...field, fieldType: { type: 'select', options }}} field={{...field, fieldType: { type: 'select', options }}}
value={value} value={value}
onChange={onChange} onChange={onChange}
onStartEdit={onStartEdit}
onEndEdit={onEndEdit}
options={options} options={options}
hasErrors={hasErrors} hasErrors={hasErrors}
className={className} className={className}
@@ -127,6 +133,8 @@ const BaseCellContent = React.memo(({
field={field} field={field}
value={value} value={value}
onChange={onChange} onChange={onChange}
onStartEdit={onStartEdit}
onEndEdit={onEndEdit}
options={options} options={options}
hasErrors={hasErrors} hasErrors={hasErrors}
className={className} className={className}
@@ -141,6 +149,8 @@ const BaseCellContent = React.memo(({
field={field} field={field}
value={value} value={value}
onChange={onChange} onChange={onChange}
onStartEdit={onStartEdit}
onEndEdit={onEndEdit}
options={options} options={options}
hasErrors={hasErrors} hasErrors={hasErrors}
className={className} className={className}
@@ -154,6 +164,8 @@ const BaseCellContent = React.memo(({
field={field} field={field}
value={value} value={value}
onChange={onChange} onChange={onChange}
onStartEdit={onStartEdit}
onEndEdit={onEndEdit}
hasErrors={hasErrors} hasErrors={hasErrors}
isMultiline={isMultiline} isMultiline={isMultiline}
isPrice={isPrice} isPrice={isPrice}
@@ -191,6 +203,8 @@ export interface ValidationCellProps {
rowIndex: number rowIndex: number
copyDown?: (endRowIndex?: number) => void copyDown?: (endRowIndex?: number) => void
totalRows?: number totalRows?: number
editingCells: Set<string>
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
} }
// Add efficient error message extraction function // Add efficient error message extraction function
@@ -288,7 +302,9 @@ const ValidationCell = React.memo(({
width, width,
copyDown, copyDown,
rowIndex, rowIndex,
totalRows = 0 totalRows = 0,
editingCells,
setEditingCells
}: ValidationCellProps) => { }: ValidationCellProps) => {
// Use the CopyDown context // Use the CopyDown context
const copyDownContext = React.useContext(CopyDownContext); const copyDownContext = React.useContext(CopyDownContext);
@@ -297,9 +313,6 @@ const ValidationCell = React.memo(({
// This ensures that when the itemNumber changes, the display value changes // This ensures that when the itemNumber changes, the display value changes
let displayValue; let displayValue;
if (fieldKey === 'item_number' && itemNumber) { if (fieldKey === 'item_number' && itemNumber) {
// Always log when an item_number field is rendered to help debug
console.log(`[VC-DEBUG] ValidationCell rendering item_number for row=${rowIndex} with itemNumber=${itemNumber}, value=${value}`);
// Prioritize itemNumber prop for item_number fields // Prioritize itemNumber prop for item_number fields
displayValue = itemNumber; displayValue = itemNumber;
} else { } else {
@@ -324,6 +337,22 @@ 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);
// PERFORMANCE FIX: Create cell key for editing state management
const cellKey = `${rowIndex}-${fieldKey}`;
// SINGLE-CLICK EDITING FIX: Create editing state management functions
const handleStartEdit = React.useCallback(() => {
setEditingCells(prev => new Set([...prev, cellKey]));
}, [setEditingCells, cellKey]);
const handleEndEdit = React.useCallback(() => {
setEditingCells(prev => {
const newSet = new Set(prev);
newSet.delete(cellKey);
return newSet;
});
}, [setEditingCells, cellKey]);
// Force isValidating to be a boolean // Force isValidating to be a boolean
const isLoading = isValidating === true; const isLoading = isValidating === true;
@@ -461,6 +490,8 @@ const ValidationCell = React.memo(({
options={options} options={options}
className={cellClassName} className={cellClassName}
fieldKey={fieldKey} fieldKey={fieldKey}
onStartEdit={handleStartEdit}
onEndEdit={handleEndEdit}
/> />
</div> </div>
)} )}

View File

@@ -61,7 +61,9 @@ const ValidationContainer = <T extends string>({
fields, fields,
isLoadingTemplates, isLoadingTemplates,
validatingCells, validatingCells,
setValidatingCells setValidatingCells,
editingCells,
setEditingCells
} = validationState } = validationState
// Use product lines fetching hook // Use product lines fetching hook
@@ -941,6 +943,8 @@ const ValidationContainer = <T extends string>({
filters={filters} filters={filters}
templates={templates} templates={templates}
applyTemplate={applyTemplateWrapper} applyTemplate={applyTemplateWrapper}
editingCells={editingCells}
setEditingCells={setEditingCells}
getTemplateDisplayText={getTemplateDisplayText} getTemplateDisplayText={getTemplateDisplayText}
isValidatingUpc={isRowValidatingUpc} isValidatingUpc={isRowValidatingUpc}
validatingUpcRows={Array.from(upcValidation.validatingRows)} validatingUpcRows={Array.from(upcValidation.validatingRows)}

View File

@@ -46,6 +46,8 @@ interface ValidationTableProps<T extends string> {
itemNumbers: Map<number, string> itemNumbers: Map<number, string>
isLoadingTemplates?: boolean isLoadingTemplates?: boolean
copyDown: (rowIndex: number, key: string, endRowIndex?: number) => void copyDown: (rowIndex: number, key: string, endRowIndex?: number) => void
editingCells: Set<string>
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
[key: string]: any [key: string]: any
} }
@@ -106,7 +108,9 @@ const MemoizedCell = React.memo(({
width, width,
rowIndex, rowIndex,
copyDown, copyDown,
totalRows totalRows,
editingCells,
setEditingCells
}: { }: {
field: Field<string>, field: Field<string>,
value: any, value: any,
@@ -119,7 +123,9 @@ const MemoizedCell = React.memo(({
width: number, width: number,
rowIndex: number, rowIndex: number,
copyDown?: (endRowIndex?: number) => void, copyDown?: (endRowIndex?: number) => void,
totalRows: number totalRows: number,
editingCells: Set<string>,
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
}) => { }) => {
return ( return (
<ValidationCell <ValidationCell
@@ -135,6 +141,8 @@ const MemoizedCell = React.memo(({
rowIndex={rowIndex} rowIndex={rowIndex}
copyDown={copyDown} copyDown={copyDown}
totalRows={totalRows} totalRows={totalRows}
editingCells={editingCells}
setEditingCells={setEditingCells}
/> />
); );
}, (prev, next) => { }, (prev, next) => {
@@ -146,6 +154,7 @@ const MemoizedCell = React.memo(({
} }
// Simplified memo comparison - most expensive checks removed // Simplified memo comparison - most expensive checks removed
// Note: editingCells changes are not checked here as they need immediate re-renders
return prev.value === next.value && return prev.value === next.value &&
prev.isValidating === next.isValidating && prev.isValidating === next.isValidating &&
prev.errors === next.errors && prev.errors === next.errors &&
@@ -169,6 +178,8 @@ const ValidationTable = <T extends string>({
itemNumbers, itemNumbers,
isLoadingTemplates = false, isLoadingTemplates = false,
copyDown, copyDown,
editingCells,
setEditingCells,
rowProductLines = {}, rowProductLines = {},
rowSublines = {}, rowSublines = {},
isLoadingLines = {}, isLoadingLines = {},
@@ -463,6 +474,8 @@ const ValidationTable = <T extends string>({
rowIndex={row.index} rowIndex={row.index}
copyDown={(endRowIndex?: number) => handleCopyDown(row.index, field.key as string, endRowIndex)} copyDown={(endRowIndex?: number) => handleCopyDown(row.index, field.key as string, endRowIndex)}
totalRows={data.length} totalRows={data.length}
editingCells={editingCells}
setEditingCells={setEditingCells}
/> />
); );
} }

View File

@@ -142,10 +142,10 @@ const SelectCell = <T extends string>({
// 5. Call onChange synchronously to avoid race conditions with other cells // 5. Call onChange synchronously to avoid race conditions with other cells
onChange(valueToCommit); onChange(valueToCommit);
// 6. Clear processing state after a short delay // 6. Clear processing state after a short delay - reduced for responsiveness
setTimeout(() => { setTimeout(() => {
setIsProcessing(false); setIsProcessing(false);
}, 200); }, 50);
}, [onChange, onEndEdit]); }, [onChange, onEndEdit]);
// If disabled, render a static view // If disabled, render a static view

View File

@@ -255,7 +255,7 @@ export const useRowOperations = <T extends string>(
return newData; return newData;
}); });
} }
}, 50); }, 5); // Reduced delay for faster secondary effects
}, },
[data, fields, validateFieldFromHook, setData, setValidationErrors] [data, fields, validateFieldFromHook, setData, setValidationErrors]
); );

View File

@@ -10,8 +10,6 @@ export const useUniqueItemNumbersValidation = <T extends string>(
) => { ) => {
// Update validateUniqueItemNumbers to also check for uniqueness of UPC/barcode // Update validateUniqueItemNumbers to also check for uniqueness of UPC/barcode
const validateUniqueItemNumbers = useCallback(async () => { const validateUniqueItemNumbers = useCallback(async () => {
console.log("Validating unique fields");
// Skip if no data // Skip if no data
if (!data.length) return; if (!data.length) return;
@@ -23,11 +21,6 @@ export const useUniqueItemNumbersValidation = <T extends string>(
.filter((field) => field.validations?.some((v) => v.rule === "unique")) .filter((field) => field.validations?.some((v) => v.rule === "unique"))
.map((field) => String(field.key)); .map((field) => String(field.key));
console.log(
`Found ${uniqueFields.length} fields requiring uniqueness validation:`,
uniqueFields
);
// Always check item_number uniqueness even if not explicitly defined // Always check item_number uniqueness even if not explicitly defined
if (!uniqueFields.includes("item_number")) { if (!uniqueFields.includes("item_number")) {
uniqueFields.push("item_number"); uniqueFields.push("item_number");
@@ -41,32 +34,44 @@ export const useUniqueItemNumbersValidation = <T extends string>(
// Initialize batch updates // Initialize batch updates
const errors = new Map<number, Record<string, ValidationError[]>>(); const errors = new Map<number, Record<string, ValidationError[]>>();
// Single pass through data to identify all unique values // ASYNC: Single pass through data to identify all unique values in batches
data.forEach((row, index) => { const BATCH_SIZE = 20;
uniqueFields.forEach((fieldKey) => { for (let batchStart = 0; batchStart < data.length; batchStart += BATCH_SIZE) {
const value = row[fieldKey as keyof typeof row]; const batchEnd = Math.min(batchStart + BATCH_SIZE, data.length);
// Skip empty values for (let index = batchStart; index < batchEnd; index++) {
if (value === undefined || value === null || value === "") { const row = data[index];
return; uniqueFields.forEach((fieldKey) => {
} const value = row[fieldKey as keyof typeof row];
const valueStr = String(value); // Skip empty values
const fieldMap = uniqueFieldsMap.get(fieldKey); if (value === undefined || value === null || value === "") {
return;
}
if (fieldMap) { const valueStr = String(value);
// Get or initialize the array of indices for this value const fieldMap = uniqueFieldsMap.get(fieldKey);
const indices = fieldMap.get(valueStr) || [];
indices.push(index);
fieldMap.set(valueStr, indices);
}
});
});
// Process duplicates if (fieldMap) {
uniqueFields.forEach((fieldKey) => { // Get or initialize the array of indices for this value
const indices = fieldMap.get(valueStr) || [];
indices.push(index);
fieldMap.set(valueStr, indices);
}
});
}
// Yield control back to UI thread after each batch
if (batchEnd < data.length) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
// ASYNC: Process duplicates in batches to prevent UI blocking
let processedFields = 0;
for (const fieldKey of uniqueFields) {
const fieldMap = uniqueFieldsMap.get(fieldKey); const fieldMap = uniqueFieldsMap.get(fieldKey);
if (!fieldMap) return; if (!fieldMap) continue;
fieldMap.forEach((indices, value) => { fieldMap.forEach((indices, value) => {
// Only process if there are duplicates // Only process if there are duplicates
@@ -93,7 +98,13 @@ export const useUniqueItemNumbersValidation = <T extends string>(
}); });
} }
}); });
});
processedFields++;
// Yield control after every few fields to prevent UI blocking
if (processedFields % 2 === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
// Merge uniqueness errors with existing validation errors // Merge uniqueness errors with existing validation errors
setValidationErrors((prev) => { setValidationErrors((prev) => {

View File

@@ -90,6 +90,10 @@ export const useValidationState = <T extends string>({
// Add state for tracking cells in loading state // Add state for tracking cells in loading state
const [validatingCells, setValidatingCells] = useState<Set<string>>(new Set()); const [validatingCells, setValidatingCells] = useState<Set<string>>(new Set());
// Add global editing state to prevent validation during editing
const [editingCells, setEditingCells] = useState<Set<string>>(new Set());
const hasEditingCells = editingCells.size > 0;
const initialValidationDoneRef = useRef(false); const initialValidationDoneRef = useRef(false);
const isValidatingRef = useRef(false); const isValidatingRef = useRef(false);
@@ -139,15 +143,19 @@ export const useValidationState = <T extends string>({
// CRITICAL FIX: Skip validation if we're already validating to prevent infinite loops // CRITICAL FIX: Skip validation if we're already validating to prevent infinite loops
if (isValidatingRef.current) return; if (isValidatingRef.current) return;
// Debounce validation to prevent excessive calls // PERFORMANCE FIX: Skip validation while cells are being edited
if (hasEditingCells) return;
// Debounce validation to prevent excessive calls - reduced for better responsiveness
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
if (isValidatingRef.current) return; // Double-check before proceeding if (isValidatingRef.current) return; // Double-check before proceeding
if (hasEditingCells) return; // Double-check editing state
// Validation running (removed console.log for performance) // Validation running (removed console.log for performance)
isValidatingRef.current = true; isValidatingRef.current = true;
// COMPREHENSIVE validation that clears old errors and adds new ones // ASYNC validation that clears old errors and adds new ones
const validateFields = () => { const validateFields = async () => {
try { try {
// Create a complete fresh validation map // Create a complete fresh validation map
const allValidationErrors = new Map<number, Record<string, any[]>>(); const allValidationErrors = new Map<number, Record<string, any[]>>();
@@ -160,89 +168,103 @@ export const useValidationState = <T extends string>({
field.validations?.some((v) => v.rule === "regex") field.validations?.some((v) => v.rule === "regex")
); );
// Validate each row completely // ASYNC PROCESSING: Process rows in small batches to prevent UI blocking
data.forEach((row, rowIndex) => { const BATCH_SIZE = 10; // Small batch size for responsiveness
const rowErrors: Record<string, any[]> = {}; const totalRows = data.length;
// Check required fields for (let batchStart = 0; batchStart < totalRows; batchStart += BATCH_SIZE) {
requiredFields.forEach((field) => { const batchEnd = Math.min(batchStart + BATCH_SIZE, totalRows);
const key = String(field.key);
const value = row[key as keyof typeof row];
// Check if field is empty // Process this batch synchronously (fast)
if (value === undefined || value === null || value === "" || for (let rowIndex = batchStart; rowIndex < batchEnd; rowIndex++) {
(Array.isArray(value) && value.length === 0)) { const row = data[rowIndex];
const requiredValidation = field.validations?.find((v) => v.rule === "required"); const rowErrors: Record<string, any[]> = {};
rowErrors[key] = [
{
message: requiredValidation?.errorMessage || "This field is required",
level: requiredValidation?.level || "error",
source: "row",
type: "required",
},
];
}
});
// Check regex fields (only if they have values) // Check required fields
regexFields.forEach((field) => { requiredFields.forEach((field) => {
const key = String(field.key); const key = String(field.key);
const value = row[key as keyof typeof row]; const value = row[key as keyof typeof row];
// Skip empty values for regex validation // Check if field is empty
if (value === undefined || value === null || value === "") { if (value === undefined || value === null || value === "" ||
return; (Array.isArray(value) && value.length === 0)) {
} const requiredValidation = field.validations?.find((v) => v.rule === "required");
rowErrors[key] = [
const regexValidation = field.validations?.find((v) => v.rule === "regex"); {
if (regexValidation) { message: requiredValidation?.errorMessage || "This field is required",
try { level: requiredValidation?.level || "error",
const regex = new RegExp(regexValidation.value, regexValidation.flags); source: "row",
if (!regex.test(String(value))) { type: "required",
// Only add regex error if no required error exists },
if (!rowErrors[key]) { ];
rowErrors[key] = [
{
message: regexValidation.errorMessage,
level: regexValidation.level || "error",
source: "row",
type: "regex",
},
];
}
}
} catch (error) {
console.error("Invalid regex in validation:", error);
} }
} });
});
// Only add to the map if there are actually errors // Check regex fields (only if they have values)
if (Object.keys(rowErrors).length > 0) { regexFields.forEach((field) => {
allValidationErrors.set(rowIndex, rowErrors); const key = String(field.key);
const value = row[key as keyof typeof row];
// Skip empty values for regex validation
if (value === undefined || value === null || value === "") {
return;
}
const regexValidation = field.validations?.find((v) => v.rule === "regex");
if (regexValidation) {
try {
const regex = new RegExp(regexValidation.value, regexValidation.flags);
if (!regex.test(String(value))) {
// Only add regex error if no required error exists
if (!rowErrors[key]) {
rowErrors[key] = [
{
message: regexValidation.errorMessage,
level: regexValidation.level || "error",
source: "row",
type: "regex",
},
];
}
}
} catch (error) {
console.error("Invalid regex in validation:", error);
}
}
});
// Only add to the map if there are actually errors
if (Object.keys(rowErrors).length > 0) {
allValidationErrors.set(rowIndex, rowErrors);
}
} }
});
// CRITICAL: Yield control back to the UI thread after each batch
if (batchEnd < totalRows) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
// Replace validation errors completely (clears old ones) // Replace validation errors completely (clears old ones)
setValidationErrors(allValidationErrors); setValidationErrors(allValidationErrors);
// Run uniqueness validations after basic validation // Run uniqueness validations after basic validation (also async)
validateUniqueItemNumbers(); setTimeout(() => validateUniqueItemNumbers(), 0);
} finally { } finally {
// Always ensure the ref is reset, even if an error occurs // Always ensure the ref is reset, even if an error occurs - reduced delay
setTimeout(() => { setTimeout(() => {
isValidatingRef.current = false; isValidatingRef.current = false;
}, 100); }, 10);
} }
}; };
// Run validation immediately // Run validation immediately (async)
validateFields(); validateFields();
}, 50); // 50ms debounce }, 10); // Reduced debounce for better responsiveness
// Cleanup timeout on unmount or dependency change // Cleanup timeout on unmount or dependency change
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [data, fields]); // Removed validateUniqueItemNumbers to prevent infinite loop }, [data, fields, hasEditingCells]); // Added hasEditingCells to dependencies
// Add field options query // Add field options query
const { data: fieldOptionsData } = useQuery({ const { data: fieldOptionsData } = useQuery({
@@ -690,6 +712,10 @@ export const useValidationState = <T extends string>({
validatingCells, validatingCells,
setValidatingCells, setValidatingCells,
// PERFORMANCE FIX: Export editing state management
editingCells,
setEditingCells,
// Row selection // Row selection
rowSelection, rowSelection,
setRowSelection, setRowSelection,