Optimize validation table

This commit is contained in:
2025-03-10 21:59:24 -04:00
parent b69182e2c7
commit 0068d77ad9
7 changed files with 592 additions and 569 deletions

View File

@@ -376,7 +376,8 @@ export default React.memo(ValidationCell, (prev, next) => {
return ( return (
prev.value === next.value && prev.value === next.value &&
prevErrorsStr === nextErrorsStr && prevErrorsStr === nextErrorsStr &&
prevOptionsStr === nextOptionsStr // Only do the deep comparison if the references are different
(prev.options === next.options || prevOptionsStr === nextOptionsStr)
); );
} }

View File

@@ -430,7 +430,10 @@ const ValidationContainer = <T extends string>({
// Fetch product lines for the new company if rowData has __index // Fetch product lines for the new company if rowData has __index
if (rowData && rowData.__index) { if (rowData && rowData.__index) {
await fetchProductLines(rowData.__index, value.toString()); // Use setTimeout to make this non-blocking
setTimeout(async () => {
await fetchProductLines(rowData.__index, value.toString());
}, 0);
} }
} }
@@ -440,28 +443,36 @@ const ValidationContainer = <T extends string>({
if (rowDataAny.upc || rowDataAny.barcode) { if (rowDataAny.upc || rowDataAny.barcode) {
const upcValue = rowDataAny.upc || rowDataAny.barcode; const upcValue = rowDataAny.upc || rowDataAny.barcode;
// Mark this row as being validated // Run UPC validation in a non-blocking way - with a slight delay
setValidatingUpcRows(prev => { // This allows the UI to update with the selected value first
const newSet = new Set(prev); setTimeout(async () => {
newSet.add(rowIndex); try {
return newSet; // Mark this row as being validated
}); setValidatingUpcRows(prev => {
const newSet = new Set(prev);
newSet.add(rowIndex);
return newSet;
});
// Set global validation state // Set global validation state
setIsValidatingUpc(true); setIsValidatingUpc(true);
// Use supplier ID (the value being set) to validate UPC // Use supplier ID (the value being set) to validate UPC
await validateUpc(rowIndex, value.toString(), upcValue.toString()); await validateUpc(rowIndex, value.toString(), upcValue.toString());
} catch (error) {
// Update validation state console.error('Error validating UPC:', error);
setValidatingUpcRows(prev => { } finally {
const newSet = new Set(prev); // Always clean up validation state, even if there was an error
newSet.delete(rowIndex); setValidatingUpcRows(prev => {
if (newSet.size === 0) { const newSet = new Set(prev);
setIsValidatingUpc(false); newSet.delete(rowIndex);
if (newSet.size === 0) {
setIsValidatingUpc(false);
}
return newSet;
});
} }
return newSet; }, 200); // Slight delay to let the UI update first
});
} }
} }
@@ -481,7 +492,10 @@ const ValidationContainer = <T extends string>({
// Fetch sublines for the new line if rowData has __index // Fetch sublines for the new line if rowData has __index
if (rowData && rowData.__index) { if (rowData && rowData.__index) {
await fetchSublines(rowData.__index, value.toString()); // Use setTimeout to make this non-blocking
setTimeout(async () => {
await fetchSublines(rowData.__index, value.toString());
}, 0);
} }
} }
@@ -489,28 +503,35 @@ const ValidationContainer = <T extends string>({
if ((fieldKey === 'upc' || fieldKey === 'barcode') && value && rowData) { if ((fieldKey === 'upc' || fieldKey === 'barcode') && value && rowData) {
const rowDataAny = rowData as Record<string, any>; const rowDataAny = rowData as Record<string, any>;
if (rowDataAny.supplier) { if (rowDataAny.supplier) {
// Mark this row as being validated // Run UPC validation in a non-blocking way
setValidatingUpcRows(prev => { setTimeout(async () => {
const newSet = new Set(prev); try {
newSet.add(rowIndex); // Mark this row as being validated
return newSet; setValidatingUpcRows(prev => {
}); const newSet = new Set(prev);
newSet.add(rowIndex);
return newSet;
});
// Set global validation state // Set global validation state
setIsValidatingUpc(true); setIsValidatingUpc(true);
// Use supplier ID from the row data (NOT company ID) to validate UPC // Use supplier ID from the row data (NOT company ID) to validate UPC
await validateUpc(rowIndex, rowDataAny.supplier.toString(), value.toString()); await validateUpc(rowIndex, rowDataAny.supplier.toString(), value.toString());
} catch (error) {
// Update validation state console.error('Error validating UPC:', error);
setValidatingUpcRows(prev => { } finally {
const newSet = new Set(prev); // Always clean up validation state, even if there was an error
newSet.delete(rowIndex); setValidatingUpcRows(prev => {
if (newSet.size === 0) { const newSet = new Set(prev);
setIsValidatingUpc(false); newSet.delete(rowIndex);
if (newSet.size === 0) {
setIsValidatingUpc(false);
}
return newSet;
});
} }
return newSet; }, 200); // Slight delay to let the UI update first
});
} }
} }
}, [data, filteredData, updateRow, fetchProductLines, fetchSublines, validateUpc, setData]); }, [data, filteredData, updateRow, fetchProductLines, fetchSublines, validateUpc, setData]);
@@ -792,39 +813,59 @@ const ValidationContainer = <T extends string>({
}); });
}, [data, rowSelection, setData, setRowSelection]); }, [data, rowSelection, setData, setRowSelection]);
// Enhanced ValidationTable component that's aware of item numbers // Memoize handlers
const EnhancedValidationTable = useCallback((props: React.ComponentProps<typeof ValidationTable>) => { const handleFiltersChange = useCallback((newFilters: any) => {
// Create validatingCells set from validatingUpcRows updateFilters(newFilters);
const validatingCells = useMemo(() => { }, [updateFilters]);
const cells = new Set<string>();
validatingUpcRows.forEach(rowIndex => { const handleRowSelectionChange = useCallback((newSelection: RowSelectionState) => {
cells.add(`${rowIndex}-upc`); setRowSelection(newSelection);
cells.add(`${rowIndex}-item_number`); }, [setRowSelection]);
});
return cells; const handleUpdateRow = useCallback((rowIndex: number, key: T, value: any) => {
}, [validatingUpcRows]); enhancedUpdateRow(rowIndex, key, value);
}, [enhancedUpdateRow]);
// Enhanced copy down that uses enhancedUpdateRow instead of regular updateRow
const handleCopyDown = useCallback((rowIndex: number, fieldKey: string) => {
// Get the value to copy from the source row
const sourceRow = data[rowIndex];
const valueToCopy = sourceRow[fieldKey];
// Get all rows below the source row
const rowsBelow = data.slice(rowIndex + 1);
// Update each row below with the copied value
rowsBelow.forEach((_, index) => {
const targetRowIndex = rowIndex + 1 + index;
enhancedUpdateRow(targetRowIndex, fieldKey as T, valueToCopy);
});
}, [data, enhancedUpdateRow]);
// Memoize the enhanced validation table component
const EnhancedValidationTable = useMemo(() => React.memo((props: React.ComponentProps<typeof ValidationTable>) => {
// Create validatingCells set from validatingUpcRows, but only for UPC and item_number fields
// This ensures supplier fields don't disappear during UPC validation
const validatingCells = new Set<string>();
validatingUpcRows.forEach(rowIndex => {
// Only mark the UPC and item_number cells as validating, NOT the supplier
validatingCells.add(`${rowIndex}-upc`);
validatingCells.add(`${rowIndex}-item_number`);
});
// Convert itemNumbers to Map // Convert itemNumbers to Map
const itemNumbersMap = useMemo(() => const itemNumbersMap = new Map(Object.entries(itemNumbers).map(([key, value]) => [parseInt(key), value]));
new Map(Object.entries(itemNumbers).map(([key, value]) => [parseInt(key), value])),
[itemNumbers]
);
// Merge the item numbers with the data for display purposes only // Merge the item numbers with the data for display purposes only
const enhancedData = useMemo(() => { const enhancedData = props.data.map((row: any, index: number) => {
if (Object.keys(itemNumbers).length === 0) return props.data; if (itemNumbers[index]) {
return {
// Create a new array with the item numbers merged in ...row,
return props.data.map((row: any, index: number) => { item_number: itemNumbers[index]
if (itemNumbers[index]) { };
return { }
...row, return row;
item_number: itemNumbers[index] });
};
}
return row;
});
}, [props.data, itemNumbers]);
return ( return (
<ValidationTable <ValidationTable
@@ -833,40 +874,38 @@ const ValidationContainer = <T extends string>({
validatingCells={validatingCells} validatingCells={validatingCells}
itemNumbers={itemNumbersMap} itemNumbers={itemNumbersMap}
isLoadingTemplates={isLoadingTemplates} isLoadingTemplates={isLoadingTemplates}
copyDown={copyDown} copyDown={handleCopyDown}
/> />
); );
}, [validatingUpcRows, itemNumbers, isLoadingTemplates, copyDown]); }), [validatingUpcRows, itemNumbers, isLoadingTemplates, handleCopyDown]);
// Memoize the ValidationTable to prevent unnecessary re-renders // Memoize the rendered validation table
const renderValidationTable = useMemo(() => { const renderValidationTable = useMemo(() => (
return ( <EnhancedValidationTable
<EnhancedValidationTable data={filteredData}
data={filteredData} fields={fields}
fields={fields} rowSelection={rowSelection}
rowSelection={rowSelection} setRowSelection={handleRowSelectionChange}
setRowSelection={setRowSelection} updateRow={handleUpdateRow}
updateRow={updateRow} validationErrors={validationErrors}
validationErrors={validationErrors} isValidatingUpc={isRowValidatingUpc}
isValidatingUpc={isRowValidatingUpc} validatingUpcRows={Array.from(validatingUpcRows)}
validatingUpcRows={Array.from(validatingUpcRows)} filters={filters}
filters={filters} templates={templates}
templates={templates} applyTemplate={applyTemplate}
applyTemplate={applyTemplate} getTemplateDisplayText={getTemplateDisplayText}
getTemplateDisplayText={getTemplateDisplayText} validatingCells={new Set()}
validatingCells={new Set()} itemNumbers={new Map()}
itemNumbers={new Map()} isLoadingTemplates={isLoadingTemplates}
isLoadingTemplates={isLoadingTemplates} copyDown={handleCopyDown}
copyDown={copyDown} />
/> ), [
);
}, [
EnhancedValidationTable, EnhancedValidationTable,
filteredData, filteredData,
fields, fields,
rowSelection, rowSelection,
setRowSelection, handleRowSelectionChange,
updateRow, handleUpdateRow,
validationErrors, validationErrors,
isRowValidatingUpc, isRowValidatingUpc,
validatingUpcRows, validatingUpcRows,
@@ -875,7 +914,7 @@ const ValidationContainer = <T extends string>({
applyTemplate, applyTemplate,
getTemplateDisplayText, getTemplateDisplayText,
isLoadingTemplates, isLoadingTemplates,
copyDown handleCopyDown
]); ]);
// Add scroll container ref at the container level // Add scroll container ref at the container level
@@ -883,13 +922,14 @@ const ValidationContainer = <T extends string>({
const lastScrollPosition = useRef({ left: 0, top: 0 }); const lastScrollPosition = useRef({ left: 0, top: 0 });
const isScrolling = useRef(false); const isScrolling = useRef(false);
// Save scroll position when scrolling // Memoize scroll handlers
const handleScroll = useCallback(() => { const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
if (!isScrolling.current && scrollContainerRef.current) { if (!isScrolling.current) {
isScrolling.current = true; isScrolling.current = true;
const target = event.currentTarget;
lastScrollPosition.current = { lastScrollPosition.current = {
left: scrollContainerRef.current.scrollLeft, left: target.scrollLeft,
top: scrollContainerRef.current.scrollTop top: target.scrollTop
}; };
requestAnimationFrame(() => { requestAnimationFrame(() => {
isScrolling.current = false; isScrolling.current = false;
@@ -993,6 +1033,7 @@ const ValidationContainer = <T extends string>({
position: 'relative', position: 'relative',
WebkitOverflowScrolling: 'touch' // Improve scroll performance on Safari WebkitOverflowScrolling: 'touch' // Improve scroll performance on Safari
}} }}
onScroll={handleScroll}
> >
<div className="min-w-max"> {/* Force container to be at least as wide as content */} <div className="min-w-max"> {/* Force container to be at least as wide as content */}
{renderValidationTable} {renderValidationTable}

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from 'react' import React, { useMemo, useCallback } from 'react'
import { import {
useReactTable, useReactTable,
getCoreRowModel, getCoreRowModel,
@@ -67,14 +67,22 @@ const ValidationTable = <T extends string>({
}: ValidationTableProps<T>) => { }: ValidationTableProps<T>) => {
const { translations } = useRsi<T>(); const { translations } = useRsi<T>();
// Memoize the selection column // Memoize the selection column with stable callback
const handleSelectAll = useCallback((value: boolean, table: any) => {
table.toggleAllPageRowsSelected(!!value);
}, []);
const handleRowSelect = useCallback((value: boolean, row: any) => {
row.toggleSelected(!!value);
}, []);
const selectionColumn = useMemo((): ColumnDef<RowData<T>, any> => ({ const selectionColumn = useMemo((): ColumnDef<RowData<T>, any> => ({
id: 'select', id: 'select',
header: ({ table }) => ( header: ({ table }) => (
<div className="flex h-full items-center justify-center py-2"> <div className="flex h-full items-center justify-center py-2">
<Checkbox <Checkbox
checked={table.getIsAllPageRowsSelected()} checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} onCheckedChange={(value) => handleSelectAll(!!value, table)}
aria-label="Select all" aria-label="Select all"
/> />
</div> </div>
@@ -83,7 +91,7 @@ const ValidationTable = <T extends string>({
<div className="flex h-[40px] items-center justify-center"> <div className="flex h-[40px] items-center justify-center">
<Checkbox <Checkbox
checked={row.getIsSelected()} checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)} onCheckedChange={(value) => handleRowSelect(!!value, row)}
aria-label="Select row" aria-label="Select row"
/> />
</div> </div>
@@ -91,9 +99,14 @@ const ValidationTable = <T extends string>({
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
size: 50, size: 50,
}), []); }), [handleSelectAll, handleRowSelect]);
// Memoize the template column // Memoize template selection handler
const handleTemplateChange = useCallback((value: string, rowIndex: number) => {
applyTemplate(value, [rowIndex]);
}, [applyTemplate]);
// Memoize the template column with stable callback
const templateColumn = useMemo((): ColumnDef<RowData<T>, any> => ({ const templateColumn = useMemo((): ColumnDef<RowData<T>, any> => ({
accessorKey: '__template', accessorKey: '__template',
header: 'Template', header: 'Template',
@@ -114,9 +127,7 @@ const ValidationTable = <T extends string>({
<SearchableTemplateSelect <SearchableTemplateSelect
templates={templates} templates={templates}
value={templateValue || ''} value={templateValue || ''}
onValueChange={(value) => { onValueChange={(value) => handleTemplateChange(value, rowIndex)}
applyTemplate(value, [rowIndex]);
}}
getTemplateDisplayText={getTemplateDisplayText} getTemplateDisplayText={getTemplateDisplayText}
defaultBrand={defaultBrand} defaultBrand={defaultBrand}
/> />
@@ -124,9 +135,19 @@ const ValidationTable = <T extends string>({
</TableCell> </TableCell>
); );
} }
}), [templates, applyTemplate, getTemplateDisplayText, isLoadingTemplates, data]); }), [templates, handleTemplateChange, getTemplateDisplayText, isLoadingTemplates, data]);
// Memoize field columns // Memoize the field update handler
const handleFieldUpdate = useCallback((rowIndex: number, fieldKey: T, value: any) => {
updateRow(rowIndex, fieldKey, value);
}, [updateRow]);
// Memoize the copyDown handler
const handleCopyDown = useCallback((rowIndex: number, fieldKey: string) => {
copyDown(rowIndex, fieldKey);
}, [copyDown]);
// Memoize field columns with stable handlers
const fieldColumns = useMemo(() => fields.map((field): ColumnDef<RowData<T>, any> | null => { const fieldColumns = useMemo(() => fields.map((field): ColumnDef<RowData<T>, any> | null => {
if (field.disabled) return null; if (field.disabled) return null;
@@ -147,7 +168,7 @@ const ValidationTable = <T extends string>({
<ValidationCell <ValidationCell
field={field} field={field}
value={row.original[field.key]} value={row.original[field.key]}
onChange={(value) => updateRow(row.index, field.key, value)} onChange={(value) => handleFieldUpdate(row.index, field.key, value)}
errors={validationErrors.get(row.index)?.[String(field.key)] || []} errors={validationErrors.get(row.index)?.[String(field.key)] || []}
isValidating={validatingCells.has(`${row.index}-${field.key}`)} isValidating={validatingCells.has(`${row.index}-${field.key}`)}
fieldKey={String(field.key)} fieldKey={String(field.key)}
@@ -155,11 +176,12 @@ const ValidationTable = <T extends string>({
itemNumber={itemNumbers.get(row.index)} itemNumber={itemNumbers.get(row.index)}
width={fieldWidth} width={fieldWidth}
rowIndex={row.index} rowIndex={row.index}
copyDown={() => copyDown(row.index, field.key)} copyDown={() => handleCopyDown(row.index, field.key)}
/> />
) )
}; };
}).filter((col): col is ColumnDef<RowData<T>, any> => col !== null), [fields, validationErrors, validatingCells, itemNumbers, updateRow, copyDown]); }).filter((col): col is ColumnDef<RowData<T>, any> => col !== null),
[fields, validationErrors, validatingCells, itemNumbers, handleFieldUpdate, handleCopyDown]);
// Combine columns // Combine columns
const columns = useMemo(() => [selectionColumn, templateColumn, ...fieldColumns], [selectionColumn, templateColumn, fieldColumns]); const columns = useMemo(() => [selectionColumn, templateColumn, ...fieldColumns], [selectionColumn, templateColumn, fieldColumns]);
@@ -247,13 +269,43 @@ const ValidationTable = <T extends string>({
); );
}; };
export default React.memo(ValidationTable, (prev, next) => { // Optimize memo comparison
// Add more specific checks to prevent unnecessary re-renders const areEqual = (prev: ValidationTableProps<any>, next: ValidationTableProps<any>) => {
if (prev.data.length !== next.data.length) return false; // Check reference equality for simple props first
if (prev.validationErrors.size !== next.validationErrors.size) return false; if (prev.fields !== next.fields) return false;
if (prev.templates !== next.templates) return false;
if (prev.isLoadingTemplates !== next.isLoadingTemplates) return false;
if (prev.filters?.showErrorsOnly !== next.filters?.showErrorsOnly) return false; if (prev.filters?.showErrorsOnly !== next.filters?.showErrorsOnly) return false;
// Check data length and content
if (prev.data.length !== next.data.length) return false;
// Check row selection changes
const prevSelectionKeys = Object.keys(prev.rowSelection);
const nextSelectionKeys = Object.keys(next.rowSelection);
if (prevSelectionKeys.length !== nextSelectionKeys.length) return false;
if (!prevSelectionKeys.every(key => prev.rowSelection[key] === next.rowSelection[key])) return false;
// Check validation errors
if (prev.validationErrors.size !== next.validationErrors.size) return false;
for (const [key, value] of prev.validationErrors) {
const nextValue = next.validationErrors.get(key);
if (!nextValue || Object.keys(value).length !== Object.keys(nextValue).length) return false;
}
// Check validating cells
if (prev.validatingCells.size !== next.validatingCells.size) return false; if (prev.validatingCells.size !== next.validatingCells.size) return false;
for (const cell of prev.validatingCells) {
if (!next.validatingCells.has(cell)) return false;
}
// Check item numbers
if (prev.itemNumbers.size !== next.itemNumbers.size) return false; if (prev.itemNumbers.size !== next.itemNumbers.size) return false;
if (prev.templates.length !== next.templates.length) return false; for (const [key, value] of prev.itemNumbers) {
if (next.itemNumbers.get(key) !== value) return false;
}
return true; return true;
}); };
export default React.memo(ValidationTable, areEqual);

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react' import React, { useState, useCallback, useDeferredValue, useTransition } from 'react'
import { Field } from '../../../../types' import { Field } from '../../../../types'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
@@ -13,6 +13,7 @@ interface InputCellProps<T extends string> {
hasErrors?: boolean hasErrors?: boolean
isMultiline?: boolean isMultiline?: boolean
isPrice?: boolean isPrice?: boolean
disabled?: boolean
} }
const InputCell = <T extends string>({ const InputCell = <T extends string>({
@@ -22,12 +23,15 @@ const InputCell = <T extends string>({
onEndEdit, onEndEdit,
hasErrors, hasErrors,
isMultiline = false, isMultiline = false,
isPrice = false isPrice = false,
disabled = false
}: InputCellProps<T>) => { }: InputCellProps<T>) => {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState('') const [editValue, setEditValue] = useState('')
const [isPending, startTransition] = useTransition()
const deferredEditValue = useDeferredValue(editValue)
// Handle focus event // Handle focus event - optimized to be synchronous
const handleFocus = useCallback(() => { const handleFocus = useCallback(() => {
setIsEditing(true) setIsEditing(true)
@@ -43,63 +47,59 @@ const InputCell = <T extends string>({
onStartEdit?.() onStartEdit?.()
}, [value, onStartEdit, isPrice]) }, [value, onStartEdit, isPrice])
// Handle blur event // Handle blur event - use transition for non-critical updates
const handleBlur = useCallback(() => { const handleBlur = useCallback(() => {
setIsEditing(false) startTransition(() => {
setIsEditing(false)
// Format the value for storage (remove formatting like $ for price) // Format the value for storage (remove formatting like $ for price)
let processedValue = editValue let processedValue = deferredEditValue
if (isPrice) { if (isPrice) {
// Remove any non-numeric characters except decimal point // Remove any non-numeric characters except decimal point
processedValue = editValue.replace(/[^\d.]/g, '') processedValue = deferredEditValue.replace(/[^\d.]/g, '')
// Parse as float and format to 2 decimal places to ensure valid number // Parse as float and format to 2 decimal places to ensure valid number
const numValue = parseFloat(processedValue) const numValue = parseFloat(processedValue)
if (!isNaN(numValue)) { if (!isNaN(numValue)) {
processedValue = numValue.toFixed(2) processedValue = numValue.toFixed(2)
}
} }
}
onChange(processedValue) onChange(processedValue)
onEndEdit?.() onEndEdit?.()
}, [editValue, onChange, onEndEdit, isPrice]) })
}, [deferredEditValue, onChange, onEndEdit, isPrice])
// Handle direct input change // 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>) => {
let newValue = e.target.value const newValue = isPrice ? e.target.value.replace(/[$,]/g, '') : e.target.value
// For price fields, automatically strip dollar signs as they type
if (isPrice) {
newValue = newValue.replace(/[$,]/g, '')
// If they try to enter a dollar sign, just remove it immediately
if (e.target.value.includes('$')) {
e.target.value = newValue
}
}
setEditValue(newValue) setEditValue(newValue)
}, [isPrice]) }, [isPrice])
// Format price value for display // Format price value for display - memoized and deferred
const getDisplayValue = useCallback(() => { const displayValue = useDeferredValue(
if (!isPrice || !value) return value isPrice && value ?
parseFloat(String(value).replace(/[^\d.]/g, '')).toFixed(2) :
// Extract numeric part value ?? ''
const numericValue = String(value).replace(/[^\d.]/g, '') )
// Parse as float and format without dollar sign
const numValue = parseFloat(numericValue)
if (isNaN(numValue)) return value
// Return just the number without dollar sign
return numValue.toFixed(2)
}, [value, isPrice])
// Add outline even when not in focus // Add outline even when not in focus
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0" const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
// If disabled, just render the value without any interactivity
if (disabled) {
return (
<div className={cn(
"px-3 py-2 h-10 rounded-md text-sm w-full",
outlineClass,
hasErrors ? "border-destructive" : "border-input"
)}>
{displayValue}
</div>
);
}
return ( return (
<div className="w-full"> <div className="w-full">
{isMultiline ? ( {isMultiline ? (
@@ -125,7 +125,8 @@ const InputCell = <T extends string>({
autoFocus autoFocus
className={cn( className={cn(
outlineClass, outlineClass,
hasErrors ? "border-destructive" : "" hasErrors ? "border-destructive" : "",
isPending ? "opacity-50" : ""
)} )}
/> />
) : ( ) : (
@@ -137,7 +138,7 @@ const InputCell = <T extends string>({
hasErrors ? "border-destructive" : "border-input" hasErrors ? "border-destructive" : "border-input"
)} )}
> >
{isPrice ? getDisplayValue() : (value ?? '')} {displayValue}
</div> </div>
) )
)} )}
@@ -145,13 +146,13 @@ const InputCell = <T extends string>({
) )
} }
// Memoize the component with a strict comparison function // Optimize memo comparison to focus on essential props
export default React.memo(InputCell, (prev, next) => { export default React.memo(InputCell, (prev, next) => {
// Only re-render if these props change if (prev.isEditing !== next.isEditing) return false;
return ( if (prev.hasErrors !== next.hasErrors) return false;
prev.value === next.value && if (prev.isMultiline !== next.isMultiline) return false;
prev.hasErrors === next.hasErrors && if (prev.isPrice !== next.isPrice) return false;
prev.isMultiline === next.isMultiline && // Only check value if not editing
prev.isPrice === next.isPrice if (!prev.isEditing && prev.value !== next.value) return false;
) return true;
}) });

View File

@@ -26,6 +26,7 @@ interface MultiInputCellProps<T extends string> {
isMultiline?: boolean isMultiline?: boolean
isPrice?: boolean isPrice?: boolean
options?: readonly FieldOption[] options?: readonly FieldOption[]
disabled?: boolean
} }
// Add global CSS to ensure fixed width constraints - use !important to override other styles // Add global CSS to ensure fixed width constraints - use !important to override other styles
@@ -41,7 +42,8 @@ const MultiInputCell = <T extends string>({
separator = ',', separator = ',',
isMultiline = false, isMultiline = false,
isPrice = false, isPrice = false,
options: providedOptions options: providedOptions,
disabled = false
}: MultiInputCellProps<T>) => { }: MultiInputCellProps<T>) => {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
@@ -180,6 +182,27 @@ const MultiInputCell = <T extends string>({
// Add outline even when not in focus // Add outline even when not in focus
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0" const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
// If disabled, render a static view
if (disabled) {
// Handle array values
const displayValue = Array.isArray(value)
? value.map(v => {
const option = providedOptions?.find(o => o.value === v);
return option ? option.label : v;
}).join(', ')
: value;
return (
<div className={cn(
"w-full px-3 py-2 h-10 rounded-md text-sm flex items-center",
"border",
hasErrors ? "border-destructive" : "border-input"
)}>
{displayValue || ""}
</div>
);
}
// If we have a multi-select field with options, use command UI // If we have a multi-select field with options, use command UI
if (field.fieldType.type === 'multi-select' && selectOptions.length > 0) { if (field.fieldType.type === 'multi-select' && selectOptions.length > 0) {
// Get width from field if available, or default to a reasonable value // Get width from field if available, or default to a reasonable value

View File

@@ -2,20 +2,10 @@ import { useState, useRef, useCallback, useMemo } from 'react'
import { Field } from '../../../../types' import { Field } from '../../../../types'
import { Check, ChevronsUpDown } from 'lucide-react' import { Check, ChevronsUpDown } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
Command, import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import React from 'react'
export type SelectOption = { export type SelectOption = {
label: string; label: string;
@@ -24,14 +14,16 @@ export type SelectOption = {
interface SelectCellProps<T extends string> { interface SelectCellProps<T extends string> {
field: Field<T> field: Field<T>
value: string value: any
onChange: (value: string) => void onChange: (value: any) => void
onStartEdit?: () => void onStartEdit?: () => void
onEndEdit?: () => void onEndEdit?: () => void
hasErrors?: boolean hasErrors?: boolean
options?: readonly SelectOption[] options: readonly any[]
disabled?: boolean
} }
// Lightweight version of the select cell with minimal dependencies
const SelectCell = <T extends string>({ const SelectCell = <T extends string>({
field, field,
value, value,
@@ -39,11 +31,27 @@ const SelectCell = <T extends string>({
onStartEdit, onStartEdit,
onEndEdit, onEndEdit,
hasErrors, hasErrors,
options options = [],
disabled = false
}: SelectCellProps<T>) => { }: SelectCellProps<T>) => {
const [open, setOpen] = useState(false) // State for the open/closed state of the dropdown
// Ref for the command list to enable scrolling const [open, setOpen] = useState(false);
const commandListRef = useRef<HTMLDivElement>(null)
// Ref for the command list
const commandListRef = useRef<HTMLDivElement>(null);
// Controlled state for the internal value - this is key to prevent reopening
const [internalValue, setInternalValue] = useState(value);
// State to track if the value is being processed/validated
const [isProcessing, setIsProcessing] = useState(false);
// Update internal value when prop value changes
React.useEffect(() => {
setInternalValue(value);
// When the value prop changes, it means validation is complete
setIsProcessing(false);
}, [value]);
// Memoize options processing to avoid recalculation on every render // Memoize options processing to avoid recalculation on every render
const selectOptions = useMemo(() => { const selectOptions = useMemo(() => {
@@ -62,7 +70,6 @@ const SelectCell = <T extends string>({
})); }));
if (processedOptions.length === 0) { if (processedOptions.length === 0) {
// Add a default empty option if we have none
processedOptions.push({ label: 'No options available', value: '' }); processedOptions.push({ label: 'No options available', value: '' });
} }
@@ -71,12 +78,12 @@ const SelectCell = <T extends string>({
// Memoize display value to avoid recalculation on every render // Memoize display value to avoid recalculation on every render
const displayValue = useMemo(() => { const displayValue = useMemo(() => {
return value ? return internalValue ?
selectOptions.find((option: SelectOption) => String(option.value) === String(value))?.label || String(value) : selectOptions.find((option: SelectOption) => String(option.value) === String(internalValue))?.label || String(internalValue) :
'Select...'; 'Select...';
}, [value, selectOptions]); }, [internalValue, selectOptions]);
// Handle wheel scroll in dropdown // Handle wheel scroll in dropdown - optimized with passive event
const handleWheel = useCallback((e: React.WheelEvent) => { const handleWheel = useCallback((e: React.WheelEvent) => {
if (commandListRef.current) { if (commandListRef.current) {
e.stopPropagation(); e.stopPropagation();
@@ -84,10 +91,25 @@ const SelectCell = <T extends string>({
} }
}, []); }, []);
// Handle selection - UPDATE INTERNAL VALUE FIRST
const handleSelect = useCallback((selectedValue: string) => { const handleSelect = useCallback((selectedValue: string) => {
onChange(selectedValue); // 1. Update internal value immediately to prevent UI flicker
setInternalValue(selectedValue);
// 2. Close the dropdown immediately
setOpen(false); setOpen(false);
// 3. Set processing state to show visual indicator
setIsProcessing(true);
// 4. Only then call the onChange callback
// 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
setTimeout(() => {
onChange(selectedValue);
}, 0);
}, [onChange, onEndEdit]); }, [onChange, onEndEdit]);
// Memoize the command items to avoid recreating them on every render // Memoize the command items to avoid recreating them on every render
@@ -97,17 +119,41 @@ const SelectCell = <T extends string>({
key={option.value} key={option.value}
value={option.label} value={option.label}
onSelect={() => handleSelect(option.value)} onSelect={() => handleSelect(option.value)}
className="cursor-pointer"
> >
{option.label} {option.label}
{String(option.value) === String(value) && ( {String(option.value) === String(internalValue) && (
<Check className="ml-auto h-4 w-4" /> <Check className="ml-auto h-4 w-4" />
)} )}
</CommandItem> </CommandItem>
)); ));
}, [selectOptions, value, handleSelect]); }, [selectOptions, internalValue, handleSelect]);
// If disabled, render a static view
if (disabled) {
const selectedOption = options.find(o => o.value === internalValue);
const displayText = selectedOption ? selectedOption.label : internalValue;
return (
<div className={cn(
"w-full px-3 py-2 h-10 rounded-md text-sm flex items-center",
"border",
hasErrors ? "border-destructive" : "border-input",
isProcessing ? "text-muted-foreground" : ""
)}>
{displayText || ""}
</div>
);
}
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (isOpen && onStartEdit) onStartEdit();
}}
>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
@@ -116,21 +162,33 @@ const SelectCell = <T extends string>({
className={cn( className={cn(
"w-full justify-between font-normal", "w-full justify-between font-normal",
"border", "border",
!value && "text-muted-foreground", !internalValue && "text-muted-foreground",
isProcessing && "text-muted-foreground",
hasErrors ? "border-destructive" : "" hasErrors ? "border-destructive" : ""
)} )}
onClick={() => { onClick={(e) => {
setOpen(!open) e.preventDefault();
if (onStartEdit) onStartEdit() e.stopPropagation();
setOpen(!open);
if (!open && onStartEdit) onStartEdit();
}} }}
> >
{displayValue} <span className={isProcessing ? "opacity-70" : ""}>
{displayValue}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent
<Command> className="p-0 w-[var(--radix-popover-trigger-width)]"
<CommandInput placeholder="Search..." /> align="start"
sideOffset={4}
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search..."
className="h-9"
/>
<CommandList <CommandList
ref={commandListRef} ref={commandListRef}
onWheel={handleWheel} onWheel={handleWheel}
@@ -147,4 +205,13 @@ const SelectCell = <T extends string>({
) )
} }
export default SelectCell // Optimize memo comparison to avoid unnecessary re-renders
export default React.memo(SelectCell, (prev, next) => {
// Only rerender when these critical props change
return (
prev.value === next.value &&
prev.hasErrors === next.hasErrors &&
prev.disabled === next.disabled &&
prev.options === next.options
);
});

View File

@@ -78,6 +78,21 @@ declare global {
// Use a helper to get API URL consistently // Use a helper to get API URL consistently
export const getApiUrl = () => config.apiUrl; export const getApiUrl = () => config.apiUrl;
// Add debounce utility
const DEBOUNCE_DELAY = 300;
const BATCH_SIZE = 5;
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
// Main validation state hook // Main validation state hook
export const useValidationState = <T extends string>({ export const useValidationState = <T extends string>({
initialData, initialData,
@@ -165,7 +180,7 @@ export const useValidationState = <T extends string>({
const [isValidating] = useState(false) const [isValidating] = useState(false)
const [validationErrors, setValidationErrors] = useState<Map<number, Record<string, ErrorType[]>>>(new Map()) const [validationErrors, setValidationErrors] = useState<Map<number, Record<string, ErrorType[]>>>(new Map())
const [rowValidationStatus, setRowValidationStatus] = useState<Map<number, 'pending' | 'validating' | 'validated' | 'error'>>(new Map()) const [rowValidationStatus, setRowValidationStatus] = useState<Map<number, 'pending' | 'validating' | 'validated' | 'error'>>(new Map())
const [validatingCells, setValidatingCells] = useState<Set<string>>(new Set()) const [, setValidatingCells] = useState<Set<string>>(new Set())
// Template state // Template state
const [templates, setTemplates] = useState<Template[]>([]) const [templates, setTemplates] = useState<Template[]>([])
@@ -195,162 +210,118 @@ export const useValidationState = <T extends string>({
// Add debounce timer ref for item number validation // Add debounce timer ref for item number validation
// Function to validate uniqueness of item numbers across the entire table // Add batch update state
const validateItemNumberUniqueness = useCallback(() => { const pendingUpdatesRef = useRef<{
// Create a map to track item numbers and their occurrences errors: Map<number, Record<string, ErrorType[]>>,
const itemNumberMap = new Map<string, number[]>(); statuses: Map<number, 'pending' | 'validating' | 'validated' | 'error'>,
data: Array<RowData<T>>
}>({
errors: new Map(),
statuses: new Map(),
data: []
});
// First pass: collect all item numbers and their row indices // Optimized batch update function
data.forEach((row, rowIndex) => { const flushPendingUpdates = useCallback(() => {
const itemNumber = row.item_number; const updates = pendingUpdatesRef.current;
if (updates.errors.size > 0) {
setValidationErrors(prev => {
const newErrors = new Map(prev);
updates.errors.forEach((errors, rowIndex) => {
if (Object.keys(errors).length === 0) {
newErrors.delete(rowIndex);
} else {
newErrors.set(rowIndex, errors);
}
});
return newErrors;
});
updates.errors = new Map();
}
if (updates.statuses.size > 0) {
setRowValidationStatus(prev => {
const newStatuses = new Map(prev);
updates.statuses.forEach((status, rowIndex) => {
newStatuses.set(rowIndex, status);
});
return newStatuses;
});
updates.statuses = new Map();
}
if (updates.data.length > 0) {
setData(prev => {
const newData = [...prev];
updates.data.forEach((row, index) => {
newData[index] = row;
});
return newData;
});
updates.data = [];
}
}, []);
// Debounced flush updates
const debouncedFlushUpdates = useMemo(
() => debounce(flushPendingUpdates, DEBOUNCE_DELAY),
[flushPendingUpdates]
);
// Queue updates instead of immediate setState calls
const queueUpdate = useCallback((rowIndex: number, updates: {
errors?: Record<string, ErrorType[]>,
status?: 'pending' | 'validating' | 'validated' | 'error',
data?: RowData<T>
}) => {
if (updates.errors) {
pendingUpdatesRef.current.errors.set(rowIndex, updates.errors);
}
if (updates.status) {
pendingUpdatesRef.current.statuses.set(rowIndex, updates.status);
}
if (updates.data) {
pendingUpdatesRef.current.data[rowIndex] = updates.data;
}
debouncedFlushUpdates();
}, [debouncedFlushUpdates]);
// Update validateUniqueItemNumbers to use batch updates
const validateUniqueItemNumbers = useCallback(async () => {
const duplicates = new Map<string, number[]>();
const itemNumberMap = new Map<string, number>();
data.forEach((row, index) => {
const itemNumber = row.item_number?.toString();
if (itemNumber) { if (itemNumber) {
if (!itemNumberMap.has(itemNumber)) { if (itemNumberMap.has(itemNumber)) {
itemNumberMap.set(itemNumber, [rowIndex]); const existingIndex = itemNumberMap.get(itemNumber)!;
if (!duplicates.has(itemNumber)) {
duplicates.set(itemNumber, [existingIndex]);
}
duplicates.get(itemNumber)!.push(index);
} else { } else {
itemNumberMap.get(itemNumber)?.push(rowIndex); itemNumberMap.set(itemNumber, index);
} }
} }
}); });
// Only process duplicates - skip if no duplicates found duplicates.forEach((rowIndices, itemNumber) => {
const duplicates = Array.from(itemNumberMap.entries())
.filter(([_, indices]) => indices.length > 1);
if (duplicates.length === 0) return;
// Prepare batch updates to minimize re-renders
const errorsToUpdate = new Map<number, Record<string, ErrorType[]>>();
const statusesToUpdate = new Map<number, 'error' | 'validated'>();
const rowsToUpdate: {rowIndex: number, errors: Record<string, ErrorType[]>}[] = [];
// Process only duplicates
duplicates.forEach(([, rowIndices]) => {
rowIndices.forEach(rowIndex => { rowIndices.forEach(rowIndex => {
// Collect errors for batch update const errors = {
const rowErrors = validationErrors.get(rowIndex) || {};
errorsToUpdate.set(rowIndex, {
...rowErrors,
item_number: [{ item_number: [{
message: 'Duplicate item number', message: `Duplicate item number: ${itemNumber}`,
level: 'error', level: 'error',
source: 'validation' source: 'validation'
}] }]
});
// Collect status updates
statusesToUpdate.set(rowIndex, 'error');
// Collect data updates
rowsToUpdate.push({
rowIndex,
errors: {
...(data[rowIndex].__errors || {}),
item_number: [{
message: 'Duplicate item number',
level: 'error',
source: 'validation'
}]
}
});
});
});
// Apply all updates in batch
if (errorsToUpdate.size > 0) {
// Update validation errors
setValidationErrors(prev => {
const updated = new Map(prev);
errorsToUpdate.forEach((errors, rowIndex) => {
updated.set(rowIndex, errors);
});
return updated;
});
// Update row statuses
setRowValidationStatus(prev => {
const updated = new Map(prev);
statusesToUpdate.forEach((status, rowIndex) => {
updated.set(rowIndex, status);
});
return updated;
});
// Update data rows
if (rowsToUpdate.length > 0) {
setData(prevData => {
const newData = [...prevData];
rowsToUpdate.forEach(({rowIndex, errors}) => {
if (newData[rowIndex]) {
newData[rowIndex] = {
...newData[rowIndex],
__errors: errors
};
}
});
return newData;
});
}
}
}, [data, validationErrors]);
// Effect to update data when UPC validation results change
useEffect(() => {
if (upcValidationResults.size === 0) return;
// Save scroll position
const scrollPosition = {
left: window.scrollX,
top: window.scrollY
};
// Process all updates in a single batch
const updatedData = [...data];
const updatedErrors = new Map(validationErrors);
const updatedStatus = new Map(rowValidationStatus);
let hasChanges = false;
upcValidationResults.forEach((result, rowIndex) => {
if (result.itemNumber && updatedData[rowIndex]) {
// Only update if the item number has actually changed
if (updatedData[rowIndex].item_number !== result.itemNumber) {
hasChanges = true;
// Update item number
updatedData[rowIndex] = {
...updatedData[rowIndex],
item_number: result.itemNumber
}; };
queueUpdate(rowIndex, { errors });
// Clear item_number errors });
const rowErrors = {...(updatedErrors.get(rowIndex) || {})};
delete rowErrors.item_number;
if (Object.keys(rowErrors).length > 0) {
updatedErrors.set(rowIndex, rowErrors);
} else {
updatedStatus.set(rowIndex, 'validated');
}
}
}
});
// Only update state if there were changes
if (hasChanges) {
// Apply all updates
setData(updatedData);
setValidationErrors(updatedErrors);
setRowValidationStatus(updatedStatus);
// Validate uniqueness after state updates
requestAnimationFrame(() => {
// Restore scroll position
window.scrollTo(scrollPosition.left, scrollPosition.top);
// Check for duplicate item numbers
validateItemNumberUniqueness();
}); });
}
}, [upcValidationResults, data, validationErrors, rowValidationStatus, validateItemNumberUniqueness]); debouncedFlushUpdates();
}, [data, queueUpdate, debouncedFlushUpdates]);
// Fetch product by UPC from API - optimized with proper error handling and types // Fetch product by UPC from API - optimized with proper error handling and types
const fetchProductByUpc = useCallback(async (supplier: string, upc: string): Promise<ValidationResult> => { const fetchProductByUpc = useCallback(async (supplier: string, upc: string): Promise<ValidationResult> => {
@@ -441,146 +412,86 @@ useEffect(() => {
} }
}, []); }, []);
// Add batch validation queue
const validationQueueRef = useRef<{rowIndex: number, supplierId: string, upcValue: string}[]>([]);
const isProcessingBatchRef = useRef(false);
// Process validation queue in batches
const processBatchValidation = useCallback(async () => {
if (isProcessingBatchRef.current) return;
if (validationQueueRef.current.length === 0) return;
isProcessingBatchRef.current = true;
const batch = validationQueueRef.current.splice(0, BATCH_SIZE);
try {
await Promise.all(batch.map(async ({ rowIndex, supplierId, upcValue }) => {
// Skip if already validated
const cacheKey = `${supplierId}-${upcValue}`;
if (processedUpcMapRef.current.has(cacheKey)) return;
const result = await fetchProductByUpc(supplierId, upcValue);
if (!result.error && result.data?.itemNumber) {
processedUpcMapRef.current.set(cacheKey, result.data.itemNumber);
setUpcValidationResults(prev => {
const newResults = new Map(prev);
newResults.set(rowIndex, { itemNumber: result.data?.itemNumber || '' });
return newResults;
});
}
}));
} finally {
isProcessingBatchRef.current = false;
// Process next batch if queue not empty
if (validationQueueRef.current.length > 0) {
processBatchValidation();
}
}
}, [fetchProductByUpc]);
// Debounced version of processBatchValidation
const debouncedProcessBatch = useMemo(
() => debounce(processBatchValidation, DEBOUNCE_DELAY),
[processBatchValidation]
);
// Modified validateUpc to use queue
const validateUpc = useCallback(async (rowIndex: number, supplierId: string, upcValue: string): Promise<{ success: boolean, itemNumber?: string }> => { const validateUpc = useCallback(async (rowIndex: number, supplierId: string, upcValue: string): Promise<{ success: boolean, itemNumber?: string }> => {
try { try {
// Skip if either value is missing
if (!supplierId || !upcValue) { if (!supplierId || !upcValue) {
return { success: false }; return { success: false };
} }
// Mark this row as being validated
setValidatingUpcRows((prev: number[]) => {
return [...prev, rowIndex];
});
// Check cache first // Check cache first
const cacheKey = `${supplierId}-${upcValue}`; const cacheKey = `${supplierId}-${upcValue}`;
if (processedUpcMapRef.current.has(cacheKey)) { if (processedUpcMapRef.current.has(cacheKey)) {
const cachedItemNumber = processedUpcMapRef.current.get(cacheKey); const cachedItemNumber = processedUpcMapRef.current.get(cacheKey);
if (cachedItemNumber) { if (cachedItemNumber) {
// Update with cached item number
setUpcValidationResults(prev => { setUpcValidationResults(prev => {
const newResults = new Map(prev); const newResults = new Map(prev);
newResults.set(rowIndex, { itemNumber: cachedItemNumber }); newResults.set(rowIndex, { itemNumber: cachedItemNumber });
return newResults; return newResults;
}); });
// Remove from validating state
setValidatingUpcRows(prev => prev.filter(idx => idx !== rowIndex));
return { success: true, itemNumber: cachedItemNumber }; return { success: true, itemNumber: cachedItemNumber };
} }
// Remove from validating state
setValidatingUpcRows(prev => prev.filter(idx => idx !== rowIndex));
return { success: false }; return { success: false };
} }
// Make API call to validate UPC // Add to validation queue
const apiResult = await fetchProductByUpc(supplierId, upcValue); validationQueueRef.current.push({ rowIndex, supplierId, upcValue });
setValidatingUpcRows(prev => [...prev, rowIndex]);
// Remove from validating state now that call is complete // Trigger batch processing
setValidatingUpcRows(prev => prev.filter(idx => idx !== rowIndex)); debouncedProcessBatch();
if (apiResult.error) { return { success: true };
// Handle error case
if (apiResult.message && apiResult.message.includes('already exists') && apiResult.data?.itemNumber) {
// UPC already exists - update with existing item number
processedUpcMapRef.current.set(cacheKey, apiResult.data.itemNumber);
setUpcValidationResults(prev => {
const newResults = new Map(prev);
if (apiResult.data?.itemNumber) {
newResults.set(rowIndex, { itemNumber: apiResult.data.itemNumber });
}
return newResults;
});
return { success: true, itemNumber: apiResult.data.itemNumber };
} else {
// Other error - show validation error
setValidationErrors(prev => {
const newErrors = new Map(prev);
const rowErrors = {...(newErrors.get(rowIndex) || {})};
rowErrors.upc = [{
message: apiResult.message || 'Invalid UPC',
level: 'error',
source: 'validation'
}];
newErrors.set(rowIndex, rowErrors);
return newErrors;
});
// Update data errors too
setData(prevData => {
const newData = [...prevData];
if (newData[rowIndex]) {
const rowErrors = {...(newData[rowIndex].__errors || {})};
rowErrors.upc = [{
message: apiResult.message || 'Invalid UPC',
level: 'error',
source: 'validation'
}];
newData[rowIndex] = {
...newData[rowIndex],
__errors: rowErrors
};
}
return newData;
});
return { success: false };
}
} else if (apiResult.data && apiResult.data.itemNumber) {
// Success case - update with new item number
processedUpcMapRef.current.set(cacheKey, apiResult.data.itemNumber);
setUpcValidationResults(prev => {
const newResults = new Map(prev);
if (apiResult.data?.itemNumber) {
newResults.set(rowIndex, { itemNumber: apiResult.data.itemNumber });
}
return newResults;
});
// Clear UPC errors
setValidationErrors(prev => {
const newErrors = new Map(prev);
const rowErrors = {...(newErrors.get(rowIndex) || {})};
if ('upc' in rowErrors) {
delete rowErrors.upc;
}
if (Object.keys(rowErrors).length > 0) {
newErrors.set(rowIndex, rowErrors);
} else {
newErrors.delete(rowIndex);
}
return newErrors;
});
return { success: true, itemNumber: apiResult.data.itemNumber };
}
return { success: false };
} catch (error) { } catch (error) {
console.error(`Error validating UPC for row ${rowIndex}:`, error); console.error('Error in validateUpc:', error);
// Remove from validating state on error
setValidatingUpcRows((prev: number[]) => prev.filter((idx: number) => idx !== rowIndex));
return { success: false }; return { success: false };
} }
}, [fetchProductByUpc, setValidatingUpcRows, setUpcValidationResults, setValidationErrors, setData]); }, [debouncedProcessBatch]);
// Track which cells are currently being validated - allows targeted re-rendering // Track which cells are currently being validated - allows targeted re-rendering
const isValidatingUpc = useCallback((rowIndex: number) => { const isValidatingUpc = useCallback((rowIndex: number) => {
@@ -912,6 +823,7 @@ useEffect(() => {
// Update all rows below with the same value using the existing updateRow function // Update all rows below with the same value using the existing updateRow function
// This ensures all validation logic runs consistently // This ensures all validation logic runs consistently
for (let i = rowIndex + 1; i < data.length; i++) { for (let i = rowIndex + 1; i < data.length; i++) {
// Just use updateRow which will handle validation with proper timing
updateRow(i, key, sourceValue); updateRow(i, key, sourceValue);
} }
}, [data, updateRow]); }, [data, updateRow]);
@@ -1450,7 +1362,7 @@ useEffect(() => {
initialValidationDoneRef.current = true; initialValidationDoneRef.current = true;
// Run item number uniqueness validation after basic validation // Run item number uniqueness validation after basic validation
validateItemNumberUniqueness(); validateUniqueItemNumbers();
} }
}); });
}; };
@@ -1460,80 +1372,6 @@ useEffect(() => {
}; };
// Function to perform UPC validations asynchronously // Function to perform UPC validations asynchronously
const runUPCValidation = async () => {
console.log('Starting UPC validation');
// Collect rows that need UPC validation
const rowsWithUpc = [];
for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
const row = data[rowIndex] as Record<string, any>;
if (row.upc && row.supplier) {
rowsWithUpc.push({ rowIndex, upc: row.upc, supplier: row.supplier });
}
}
console.log(`Found ${rowsWithUpc.length} rows with UPC and supplier`);
const BATCH_SIZE = 3;
for (let i = 0; i < rowsWithUpc.length; i += BATCH_SIZE) {
const batch = rowsWithUpc.slice(i, i + BATCH_SIZE);
await Promise.all(batch.map(async ({ rowIndex, upc, supplier }) => {
try {
const cacheKey = `${supplier}-${upc}`;
if (processedUpcMapRef.current.has(cacheKey)) {
const cachedItemNumber = processedUpcMapRef.current.get(cacheKey);
if (cachedItemNumber) {
setUpcValidationResults(prev => {
const newResults = new Map(prev);
newResults.set(rowIndex, { itemNumber: cachedItemNumber });
return newResults;
});
}
return;
}
console.log(`Validating UPC: ${upc} for supplier: ${supplier}`);
const apiResult = await fetchProductByUpc(supplier, upc);
if (apiResult && !apiResult.error && apiResult.data?.itemNumber) {
const itemNumber = apiResult.data.itemNumber;
processedUpcMapRef.current.set(cacheKey, itemNumber);
setUpcValidationResults(prev => {
const newResults = new Map(prev);
newResults.set(rowIndex, { itemNumber });
return newResults;
});
} else if (apiResult.error && apiResult.message !== 'UPC not found') {
setValidationErrors(prev => {
const newErrors = new Map(prev);
const rowErrors = newErrors.get(rowIndex) || {};
newErrors.set(rowIndex, {
...rowErrors,
upc: [{
message: apiResult.message || 'Invalid UPC',
level: 'error',
source: 'validation'
}]
});
return newErrors;
});
setRowValidationStatus(prev => {
const newStatus = new Map(prev);
newStatus.set(rowIndex, 'error');
return newStatus;
});
}
} catch (error) {
console.error('Error validating UPC:', error);
}
}));
if (i + BATCH_SIZE < rowsWithUpc.length) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
console.log('UPC validation complete');
};
// Run basic validations immediately to update UI // Run basic validations immediately to update UI
runBasicValidation(); runBasicValidation();