More validation table optimizations + create doc to track remaining fixes

This commit is contained in:
2025-03-11 16:21:17 -04:00
parent 0068d77ad9
commit 1aee18a025
8 changed files with 1425 additions and 467 deletions

View File

@@ -19,6 +19,14 @@ type ErrorObject = {
source?: string;
}
// Helper function to check if a value is empty - utility function shared by all components
const isEmpty = (val: any): boolean =>
val === undefined ||
val === null ||
val === '' ||
(Array.isArray(val) && val.length === 0) ||
(typeof val === 'object' && !Array.isArray(val) && Object.keys(val).length === 0);
// Memoized validation icon component
const ValidationIcon = React.memo(({ error }: { error: ErrorObject }) => (
<TooltipProvider>
@@ -101,11 +109,17 @@ const BaseCellContent = React.memo(({
/>
);
}, (prev, next) => {
// Shallow array comparison for options if arrays
const optionsEqual = prev.options === next.options ||
(Array.isArray(prev.options) && Array.isArray(next.options) &&
prev.options.length === next.options.length &&
prev.options.every((opt, idx) => opt === (next.options as any[])[idx]));
return (
prev.value === next.value &&
prev.hasErrors === next.hasErrors &&
prev.field === next.field &&
JSON.stringify(prev.options) === JSON.stringify(next.options)
optionsEqual
);
});
@@ -125,6 +139,82 @@ export interface ValidationCellProps {
copyDown?: () => void
}
// Add efficient error message extraction function
const getErrorMessage = (error: ErrorObject): string => error.message;
// Add a utility function to process errors with appropriate caching
function processErrors(value: any, errors: ErrorObject[]): {
filteredErrors: ErrorObject[];
hasError: boolean;
isRequiredButEmpty: boolean;
shouldShowErrorIcon: boolean;
errorMessages: string;
} {
// Fast path - if no errors, return immediately
if (!errors || errors.length === 0) {
return {
filteredErrors: [],
hasError: false,
isRequiredButEmpty: false,
shouldShowErrorIcon: false,
errorMessages: ''
};
}
// Check if value is empty - using local function for speed
const valueIsEmpty = value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0);
// If not empty, filter out required errors
// Create a new array only if we need to filter (avoid unnecessary allocations)
let filteredErrors: ErrorObject[];
let hasRequiredError = false;
if (valueIsEmpty) {
// For empty values, check if there are required errors
hasRequiredError = errors.some(error =>
error.message?.toLowerCase().includes('required')
);
filteredErrors = errors;
} else {
// For non-empty values, filter out required errors
filteredErrors = errors.filter(error =>
!error.message?.toLowerCase().includes('required')
);
}
// Determine if any actual errors exist after filtering
const hasError = filteredErrors.some(error =>
error.level === 'error' || error.level === 'warning'
);
// Check if field is required but empty
const isRequiredButEmpty = valueIsEmpty && hasRequiredError;
// Only show error icons for non-empty fields with actual errors
const shouldShowErrorIcon = hasError && !valueIsEmpty;
// Get error messages for the tooltip - only if we need to show icon
let errorMessages = '';
if (shouldShowErrorIcon) {
errorMessages = filteredErrors
.filter(e => e.level === 'error' || e.level === 'warning')
.map(getErrorMessage)
.join('\n');
}
return {
filteredErrors,
hasError,
isRequiredButEmpty,
shouldShowErrorIcon,
errorMessages
};
}
const ItemNumberCell = React.memo(({
value,
itemNumber,
@@ -144,35 +234,20 @@ const ItemNumberCell = React.memo(({
onChange: (value: any) => void,
copyDown?: () => void
}) => {
// Helper function to check if a value is empty
const isEmpty = (val: any): boolean =>
val === undefined ||
val === null ||
val === '' ||
(Array.isArray(val) && val.length === 0) ||
(typeof val === 'object' && !Array.isArray(val) && Object.keys(val).length === 0);
// If we have a value or itemNumber, ignore "required" errors
const displayValue = itemNumber || value;
const filteredErrors = !isEmpty(displayValue)
? errors.filter(error => !error.message?.toLowerCase().includes('required'))
: errors;
// Determine if the field has an error after filtering
const hasError = filteredErrors.some(error => error.level === 'error' || error.level === 'warning');
// Use the utility function to process errors once
const {
hasError,
isRequiredButEmpty,
shouldShowErrorIcon,
errorMessages
} = React.useMemo(() =>
processErrors(displayValue, errors),
[displayValue, errors]
);
// Determine if the field is required but empty
const isRequiredButEmpty = isEmpty(displayValue) &&
errors.some(error => error.message?.toLowerCase().includes('required'));
// Only show error icons for non-empty fields with actual errors (not just required errors)
const shouldShowErrorIcon = hasError && !isEmpty(displayValue);
// Get error messages for the tooltip
const errorMessages = shouldShowErrorIcon
? filteredErrors.filter(e => e.level === 'error' || e.level === 'warning').map(e => e.message).join('\n')
: '';
return (
<TableCell className="p-1 group relative" style={{ width: `${width}px`, minWidth: `${width}px` }}>
<div className={`relative ${hasError || isRequiredButEmpty ? 'border-red-500' : ''}`}>
@@ -188,7 +263,7 @@ const ItemNumberCell = React.memo(({
value={displayValue}
onChange={onChange}
hasErrors={hasError || isRequiredButEmpty}
options={[]}
options={(field.fieldType && typeof field.fieldType === 'object' && (field.fieldType as any).options) || []}
/>
</div>
)}
@@ -226,7 +301,7 @@ const ItemNumberCell = React.memo(({
prev.value === next.value &&
prev.itemNumber === next.itemNumber &&
prev.isValidating === next.isValidating &&
JSON.stringify(prev.errors) === JSON.stringify(next.errors)
compareErrorArrays(prev.errors || [], next.errors || [])
));
ItemNumberCell.displayName = 'ItemNumberCell';
@@ -241,7 +316,6 @@ const ValidationCell = ({
options = [],
itemNumber,
width,
rowIndex,
copyDown}: ValidationCellProps) => {
// For item_number fields, use the specialized component
if (fieldKey === 'item_number') {
@@ -259,43 +333,36 @@ const ValidationCell = ({
);
}
// Helper function to check if a value is empty
const isEmpty = (val: any): boolean =>
val === undefined ||
val === null ||
val === '' ||
(Array.isArray(val) && val.length === 0) ||
(typeof val === 'object' && !Array.isArray(val) && Object.keys(val).length === 0);
// If we have a value, ignore "required" errors
const filteredErrors = !isEmpty(value)
? errors.filter(error => !error.message?.toLowerCase().includes('required'))
: errors;
// Memoize filtered errors to avoid recalculation on every render
const filteredErrors = React.useMemo(() => {
return !isEmpty(value)
? errors.filter(error => !error.message?.toLowerCase().includes('required'))
: errors;
}, [value, errors]);
// Determine if the field has an error after filtering
const hasError = filteredErrors.some(error => error.level === 'error' || error.level === 'warning');
// Determine if the field is required but empty
const isRequiredButEmpty = isEmpty(value) &&
errors.some(error => error.message?.toLowerCase().includes('required'));
// Only show error icons for non-empty fields with actual errors (not just required errors)
const shouldShowErrorIcon = hasError && !isEmpty(value);
// Get error messages for the tooltip
const errorMessages = shouldShowErrorIcon
? filteredErrors.filter(e => e.level === 'error' || e.level === 'warning').map(e => e.message).join('\n')
: '';
// Memoize error state derivations
const { hasError, isRequiredButEmpty, shouldShowErrorIcon, errorMessages } = React.useMemo(() => {
// Determine if the field has an error after filtering
const hasError = filteredErrors.some(error => error.level === 'error' || error.level === 'warning');
// Determine if the field is required but empty
const isRequiredButEmpty = isEmpty(value) &&
errors.some(error => error.message?.toLowerCase().includes('required'));
// Only show error icons for non-empty fields with actual errors (not just required errors)
const shouldShowErrorIcon = hasError && !isEmpty(value);
// Get error messages for the tooltip
const errorMessages = shouldShowErrorIcon
? filteredErrors.filter(e => e.level === 'error' || e.level === 'warning').map(e => e.message).join('\n')
: '';
return { hasError, isRequiredButEmpty, shouldShowErrorIcon, errorMessages };
}, [filteredErrors, value, errors]);
// Check if this is a multiline field
const isMultiline = typeof field.fieldType === 'object' &&
(field.fieldType.type === 'input' || field.fieldType.type === 'multi-input') &&
field.fieldType.multiline === true;
// Check for price field
const isPrice = typeof field.fieldType === 'object' &&
(field.fieldType.type === 'input' || field.fieldType.type === 'multi-input') &&
field.fieldType.price === true;
return (
<TableCell className="p-1 group relative" style={{ width: `${width}px`, minWidth: `${width}px` }}>
@@ -349,42 +416,61 @@ const ValidationCell = ({
};
export default React.memo(ValidationCell, (prev, next) => {
// Deep comparison of errors
const prevErrorsStr = JSON.stringify(prev.errors);
const nextErrorsStr = JSON.stringify(next.errors);
// Deep comparison of options
const prevOptionsStr = JSON.stringify(prev.options);
const nextOptionsStr = JSON.stringify(next.options);
// For validating cells, always re-render
if (prev.isValidating !== next.isValidating) {
return false;
}
// For item numbers, check if the item number changed
if (prev.fieldKey === 'item_number') {
return (
prev.value === next.value &&
prev.itemNumber === next.itemNumber &&
prevErrorsStr === nextErrorsStr
);
// Quick reference equality checks first for better performance
if (prev.value !== next.value || prev.width !== next.width) {
return false;
}
// For select and multi-select fields, check if options changed
// Check for error arrays equality - avoid JSON.stringify
const errorsEqual = compareErrorArrays(prev.errors || [], next.errors || []);
if (!errorsEqual) return false;
// Check options only when needed
if (prev.field.fieldType.type === 'select' || prev.field.fieldType.type === 'multi-select') {
return (
prev.value === next.value &&
prevErrorsStr === nextErrorsStr &&
// Only do the deep comparison if the references are different
(prev.options === next.options || prevOptionsStr === nextOptionsStr)
);
if (prev.options !== next.options) {
// Use safe defaults for options to handle undefined
const prevOpts = prev.options || [];
const nextOpts = next.options || [];
// Only do shallow comparison if references are different
if (prevOpts.length !== nextOpts.length) return false;
// Quick length check before detailed comparison
for (let i = 0; i < prevOpts.length; i++) {
if (prevOpts[i] !== nextOpts[i]) return false;
}
}
}
// For all other fields, check if value or errors changed
return (
prev.value === next.value &&
prevErrorsStr === nextErrorsStr &&
prev.width === next.width
);
});
// For item numbers, check itemNumber equality
if (prev.fieldKey === 'item_number' && prev.itemNumber !== next.itemNumber) {
return false;
}
// If we got this far, the props are equal
return true;
});
// Helper function to compare error arrays efficiently
function compareErrorArrays(prevErrors: ErrorObject[], nextErrors: ErrorObject[]): boolean {
if (prevErrors === nextErrors) return true;
if (prevErrors.length !== nextErrors.length) return false;
for (let i = 0; i < prevErrors.length; i++) {
const prevError = prevErrors[i];
const nextError = nextErrors[i];
if (prevError.message !== nextError.message ||
prevError.level !== nextError.level ||
prevError.source !== nextError.source) {
return false;
}
}
return true;
}

View File

@@ -49,6 +49,106 @@ interface ValidationTableProps<T extends string> {
[key: string]: any
}
// Create a memoized wrapper for template selects to prevent unnecessary re-renders
const MemoizedTemplateSelect = React.memo(({
templates,
value,
onValueChange,
getTemplateDisplayText,
defaultBrand,
isLoading
}: {
templates: Template[],
value: string,
onValueChange: (value: string) => void,
getTemplateDisplayText: (value: string | null) => string,
defaultBrand?: string,
isLoading?: boolean
}) => {
if (isLoading) {
return (
<Button variant="outline" className="w-full justify-between" disabled>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Loading...
</Button>
);
}
return (
<SearchableTemplateSelect
templates={templates}
value={value}
onValueChange={onValueChange}
getTemplateDisplayText={getTemplateDisplayText}
defaultBrand={defaultBrand}
/>
);
}, (prev, next) => {
return (
prev.value === next.value &&
prev.templates === next.templates &&
prev.defaultBrand === next.defaultBrand &&
prev.isLoading === next.isLoading
);
});
MemoizedTemplateSelect.displayName = 'MemoizedTemplateSelect';
// Create a memoized cell component
const MemoizedCell = React.memo(({
field,
value,
onChange,
errors,
isValidating,
fieldKey,
options,
itemNumber,
width,
rowIndex,
copyDown
}: {
field: Field<string>,
value: any,
onChange: (value: any) => void,
errors: ErrorType[],
isValidating?: boolean,
fieldKey: string,
options?: readonly any[],
itemNumber?: string,
width: number,
rowIndex: number,
copyDown?: () => void
}) => {
return (
<ValidationCell
field={field}
value={value}
onChange={onChange}
errors={errors}
isValidating={isValidating}
fieldKey={fieldKey}
options={options}
itemNumber={itemNumber}
width={width}
rowIndex={rowIndex}
copyDown={copyDown}
/>
);
}, (prev, next) => {
// Only re-render if these essential props change
return (
prev.value === next.value &&
prev.isValidating === next.isValidating &&
prev.itemNumber === next.itemNumber &&
// Deep compare errors
prev.errors === next.errors &&
prev.options === next.options
);
});
MemoizedCell.displayName = 'MemoizedCell';
const ValidationTable = <T extends string>({
data,
fields,
@@ -118,25 +218,35 @@ const ValidationTable = <T extends string>({
return (
<TableCell className="p-1" style={{ width: '200px', minWidth: '200px' }}>
{isLoadingTemplates ? (
<Button variant="outline" className="w-full justify-between" disabled>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Loading...
</Button>
) : (
<SearchableTemplateSelect
templates={templates}
value={templateValue || ''}
onValueChange={(value) => handleTemplateChange(value, rowIndex)}
getTemplateDisplayText={getTemplateDisplayText}
defaultBrand={defaultBrand}
/>
)}
<MemoizedTemplateSelect
templates={templates}
value={templateValue || ''}
onValueChange={(value) => handleTemplateChange(value, rowIndex)}
getTemplateDisplayText={getTemplateDisplayText}
defaultBrand={defaultBrand}
isLoading={isLoadingTemplates}
/>
</TableCell>
);
}
}), [templates, handleTemplateChange, getTemplateDisplayText, isLoadingTemplates, data]);
// Cache options by field key to avoid recreating arrays
const optionsCache = useMemo(() => {
const cache = new Map<string, readonly any[]>();
fields.forEach((field) => {
if (field.disabled) return;
if (field.fieldType.type === 'select' || field.fieldType.type === 'multi-select') {
const fieldKey = String(field.key);
cache.set(fieldKey, (field.fieldType as any).options || []);
}
});
return cache;
}, [fields]);
// Memoize the field update handler
const handleFieldUpdate = useCallback((rowIndex: number, fieldKey: T, value: any) => {
updateRow(rowIndex, fieldKey, value);
@@ -160,19 +270,23 @@ const ValidationTable = <T extends string>({
150
);
const fieldKey = String(field.key);
// Get cached options for this field
const fieldOptions = optionsCache.get(fieldKey) || [];
return {
accessorKey: String(field.key),
header: field.label || String(field.key),
accessorKey: fieldKey,
header: field.label || fieldKey,
size: fieldWidth,
cell: ({ row }) => (
<ValidationCell
<MemoizedCell
field={field}
value={row.original[field.key]}
onChange={(value) => handleFieldUpdate(row.index, field.key, value)}
errors={validationErrors.get(row.index)?.[String(field.key)] || []}
errors={validationErrors.get(row.index)?.[fieldKey] || []}
isValidating={validatingCells.has(`${row.index}-${field.key}`)}
fieldKey={String(field.key)}
options={(field.fieldType as any).options || []}
fieldKey={fieldKey}
options={fieldOptions}
itemNumber={itemNumbers.get(row.index)}
width={fieldWidth}
rowIndex={row.index}
@@ -181,7 +295,7 @@ const ValidationTable = <T extends string>({
)
};
}).filter((col): col is ColumnDef<RowData<T>, any> => col !== null),
[fields, validationErrors, validatingCells, itemNumbers, handleFieldUpdate, handleCopyDown]);
[fields, validationErrors, validatingCells, itemNumbers, handleFieldUpdate, handleCopyDown, optionsCache]);
// Combine columns
const columns = useMemo(() => [selectionColumn, templateColumn, ...fieldColumns], [selectionColumn, templateColumn, fieldColumns]);
@@ -269,42 +383,29 @@ const ValidationTable = <T extends string>({
);
};
// Optimize memo comparison
// Optimize memo comparison with more efficient checks
const areEqual = (prev: ValidationTableProps<any>, next: ValidationTableProps<any>) => {
// Check reference equality for simple props first
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;
// Check data length and content
// Fast path: data length change always means re-render
if (prev.data.length !== next.data.length) return false;
// Check row selection changes
// Efficiently 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
// Use size for Map comparisons instead of deeper checks
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;
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;
for (const [key, value] of prev.itemNumbers) {
if (next.itemNumbers.get(key) !== value) return false;
}
// If values haven't changed, component doesn't need to re-render
return true;
};

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useDeferredValue, useTransition } from 'react'
import React, { useState, useCallback, useDeferredValue, useTransition, useRef, useEffect } from 'react'
import { Field } from '../../../../types'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
@@ -16,6 +16,20 @@ interface InputCellProps<T extends string> {
disabled?: boolean
}
// Add efficient price formatting utility
const formatPrice = (value: string): string => {
// Remove any non-numeric characters except decimal point
const numericValue = value.replace(/[^\d.]/g, '');
// Parse as float and format to 2 decimal places
const numValue = parseFloat(numericValue);
if (!isNaN(numValue)) {
return numValue.toFixed(2);
}
return numericValue;
};
const InputCell = <T extends string>({
value,
onChange,
@@ -26,66 +40,81 @@ const InputCell = <T extends string>({
isPrice = false,
disabled = false
}: InputCellProps<T>) => {
const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState('')
const [isPending, startTransition] = useTransition()
const deferredEditValue = useDeferredValue(editValue)
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState('');
const [isPending, startTransition] = useTransition();
const deferredEditValue = useDeferredValue(editValue);
// Use a ref to track if we need to process the value
const needsProcessingRef = useRef(false);
// Efficiently handle price formatting without multiple rerenders
useEffect(() => {
if (isPrice && needsProcessingRef.current && !isEditing) {
needsProcessingRef.current = false;
// Do price processing only when needed
const formattedValue = formatPrice(value);
if (formattedValue !== value) {
onChange(formattedValue);
}
}
}, [value, isPrice, isEditing, onChange]);
// Handle focus event - optimized to be synchronous
const handleFocus = useCallback(() => {
setIsEditing(true)
setIsEditing(true);
// For price fields, strip formatting when focusing
if (isPrice && value !== undefined && value !== null) {
// Remove any non-numeric characters except decimal point
const numericValue = String(value).replace(/[^\d.]/g, '')
setEditValue(numericValue)
if (value !== undefined && value !== null) {
if (isPrice) {
// Remove any non-numeric characters except decimal point
const numericValue = String(value).replace(/[^\d.]/g, '');
setEditValue(numericValue);
} else {
setEditValue(String(value));
}
} else {
setEditValue(value !== undefined && value !== null ? String(value) : '')
setEditValue('');
}
onStartEdit?.()
}, [value, onStartEdit, isPrice])
onStartEdit?.();
}, [value, onStartEdit, isPrice]);
// Handle blur event - use transition for non-critical updates
const handleBlur = useCallback(() => {
startTransition(() => {
setIsEditing(false)
setIsEditing(false);
// Format the value for storage (remove formatting like $ for price)
let processedValue = deferredEditValue
let processedValue = deferredEditValue.trim();
if (isPrice) {
// Remove any non-numeric characters except decimal point
processedValue = deferredEditValue.replace(/[^\d.]/g, '')
// Parse as float and format to 2 decimal places to ensure valid number
const numValue = parseFloat(processedValue)
if (!isNaN(numValue)) {
processedValue = numValue.toFixed(2)
}
if (isPrice && processedValue) {
needsProcessingRef.current = true;
}
onChange(processedValue)
onEndEdit?.()
})
}, [deferredEditValue, onChange, onEndEdit, isPrice])
onChange(processedValue);
onEndEdit?.();
});
}, [deferredEditValue, onChange, onEndEdit, isPrice]);
// Handle direct input change - optimized to be synchronous for typing
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const newValue = isPrice ? e.target.value.replace(/[$,]/g, '') : e.target.value
setEditValue(newValue)
}, [isPrice])
const newValue = isPrice ? e.target.value.replace(/[$,]/g, '') : e.target.value;
setEditValue(newValue);
}, [isPrice]);
// Format price value for display - memoized and deferred
// Display value with efficient memoization
const displayValue = useDeferredValue(
isPrice && value ?
parseFloat(String(value).replace(/[^\d.]/g, '')).toFixed(2) :
typeof value === 'number' ? value.toFixed(2) :
typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value) ? parseFloat(value).toFixed(2) :
value :
value ?? ''
)
);
// 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) {
@@ -148,11 +177,30 @@ const InputCell = <T extends string>({
// Optimize memo comparison to focus on essential props
export default React.memo(InputCell, (prev, next) => {
if (prev.isEditing !== next.isEditing) return false;
if (prev.hasErrors !== next.hasErrors) return false;
if (prev.isMultiline !== next.isMultiline) return false;
if (prev.isPrice !== next.isPrice) return false;
// Only check value if not editing
if (!prev.isEditing && prev.value !== next.value) return false;
if (prev.disabled !== next.disabled) return false;
// Only check value if not editing (to avoid expensive rerender during editing)
if (prev.value !== next.value) {
// For price values, do a more intelligent comparison
if (prev.isPrice) {
// Convert both to numeric values for comparison
const prevNum = typeof prev.value === 'number' ? prev.value :
typeof prev.value === 'string' ? parseFloat(prev.value) : 0;
const nextNum = typeof next.value === 'number' ? next.value :
typeof next.value === 'string' ? parseFloat(next.value) : 0;
// Only update if the actual numeric values differ
if (!isNaN(prevNum) && !isNaN(nextNum) &&
Math.abs(prevNum - nextNum) > 0.001) {
return false;
}
} else {
return false;
}
}
return true;
});

View File

@@ -32,6 +32,129 @@ interface MultiInputCellProps<T extends string> {
// Add global CSS to ensure fixed width constraints - use !important to override other styles
const fixedWidthClass = "!w-full !min-w-0 !max-w-full !flex-shrink-1 !flex-grow-0";
// Memoized option item to prevent unnecessary renders for large option lists
const OptionItem = React.memo(({
option,
isSelected,
onSelect
}: {
option: FieldOption,
isSelected: boolean,
onSelect: (value: string) => void
}) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => onSelect(option.value)}
className="flex w-full"
>
<div className="flex items-center w-full overflow-hidden">
<Check
className={cn(
"mr-2 h-4 w-4 flex-shrink-0",
isSelected ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate w-full">{option.label}</span>
</div>
</CommandItem>
), (prev, next) => {
return prev.option.value === next.option.value &&
prev.isSelected === next.isSelected;
});
OptionItem.displayName = 'OptionItem';
// Create a virtualized list component for large option lists
const VirtualizedOptions = React.memo(({
options,
selectedValues,
onSelect,
maxHeight = 200
}: {
options: FieldOption[],
selectedValues: Set<string>,
onSelect: (value: string) => void,
maxHeight?: number
}) => {
const listRef = useRef<HTMLDivElement>(null);
// Only render visible options for better performance with large lists
const [visibleOptions, setVisibleOptions] = useState<FieldOption[]>([]);
const [scrollPosition, setScrollPosition] = useState(0);
// Constants for virtualization
const itemHeight = 32; // Height of each option item in pixels
const visibleCount = Math.ceil(maxHeight / itemHeight) + 2; // Number of visible items + buffer
// Handle scroll events
const handleScroll = useCallback(() => {
if (listRef.current) {
setScrollPosition(listRef.current.scrollTop);
}
}, []);
// Update visible options based on scroll position
useEffect(() => {
if (options.length <= visibleCount) {
// If fewer options than visible count, just show all
setVisibleOptions(options);
return;
}
// Calculate start and end indices
const startIndex = Math.floor(scrollPosition / itemHeight);
const endIndex = Math.min(startIndex + visibleCount, options.length);
// Update visible options
setVisibleOptions(options.slice(Math.max(0, startIndex), endIndex));
}, [options, scrollPosition, visibleCount, itemHeight]);
// If fewer than the threshold, render all directly
if (options.length <= 100) {
return (
<div ref={listRef} className="max-h-[200px] overflow-y-auto" onScroll={handleScroll}>
{options.map(option => (
<OptionItem
key={option.value}
option={option}
isSelected={selectedValues.has(option.value)}
onSelect={onSelect}
/>
))}
</div>
);
}
return (
<div
ref={listRef}
className="max-h-[200px] overflow-y-auto"
onScroll={handleScroll}
style={{ height: `${Math.min(maxHeight, options.length * itemHeight)}px` }}
>
<div style={{ height: `${options.length * itemHeight}px`, position: 'relative' }}>
<div style={{
position: 'absolute',
top: `${Math.floor(scrollPosition / itemHeight) * itemHeight}px`,
width: '100%'
}}>
{visibleOptions.map(option => (
<OptionItem
key={option.value}
option={option}
isSelected={selectedValues.has(option.value)}
onSelect={onSelect}
/>
))}
</div>
</div>
</div>
);
});
VirtualizedOptions.displayName = 'VirtualizedOptions';
const MultiInputCell = <T extends string>({
field,
value = [],
@@ -52,6 +175,9 @@ const MultiInputCell = <T extends string>({
// Ref for the command list to enable scrolling
const commandListRef = useRef<HTMLDivElement>(null)
// Create a memoized Set for fast lookups of selected values
const selectedValueSet = useMemo(() => new Set(internalValue), [internalValue]);
// Sync internalValue with external value when component mounts or value changes externally
useEffect(() => {
if (!open) {
@@ -74,6 +200,7 @@ const MultiInputCell = <T extends string>({
} else if (newOpen) {
// Sync internal state with external state when opening
setInternalValue(value);
setSearchQuery(""); // Reset search query on open
if (onStartEdit) onStartEdit();
}
}, [open, internalValue, value, onChange, onStartEdit, onEndEdit]);
@@ -88,43 +215,84 @@ const MultiInputCell = <T extends string>({
[];
// Use provided options or field options, ensuring they have the correct shape
const availableOptions = (providedOptions || fieldOptions || []).map(option => ({
label: option.label || String(option.value),
value: String(option.value)
}));
// Add default option if no options available
if (availableOptions.length === 0) {
availableOptions.push({ label: 'No options available', value: '' });
// Skip this work if we have a large number of options and they didn't change
if (providedOptions && providedOptions.length > 0) {
// Check if options are already in the right format
if (typeof providedOptions[0] === 'object' && 'label' in providedOptions[0] && 'value' in providedOptions[0]) {
return providedOptions as FieldOption[];
}
return providedOptions.map(option => ({
label: option.label || String(option.value),
value: String(option.value)
}));
}
return availableOptions;
// Check field options format
if (fieldOptions.length > 0) {
if (typeof fieldOptions[0] === 'object' && 'label' in fieldOptions[0] && 'value' in fieldOptions[0]) {
return fieldOptions as FieldOption[];
}
return fieldOptions.map(option => ({
label: option.label || String(option.value),
value: String(option.value)
}));
}
// Add default option if no options available
return [{ label: 'No options available', value: '' }];
}, [field.fieldType, providedOptions]);
// Memoize filtered options based on search query
// Use deferredValue for search to prevent UI blocking with large lists
const deferredSearchQuery = React.useDeferredValue(searchQuery);
// Memoize filtered options based on search query - efficient filtering algorithm
const filteredOptions = useMemo(() => {
if (!searchQuery) return selectOptions;
return selectOptions.filter(option =>
option.label.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [selectOptions, searchQuery]);
// If no search query, return all options
if (!deferredSearchQuery.trim()) return selectOptions;
const query = deferredSearchQuery.toLowerCase();
// Use faster algorithm for large option lists
if (selectOptions.length > 100) {
return selectOptions.filter(option => {
// First check starting with the query (most relevant)
if (option.label.toLowerCase().startsWith(query)) return true;
// Then check includes for more general matches
return option.label.toLowerCase().includes(query);
});
}
// Sort options with selected items at the top for the dropdown
// For smaller lists, do full text search
return selectOptions.filter(option =>
option.label.toLowerCase().includes(query)
);
}, [selectOptions, deferredSearchQuery]);
// Sort options with selected items at the top for the dropdown - only for smaller lists
const sortedOptions = useMemo(() => {
// Skip expensive sorting for large lists
if (selectOptions.length > 100) return filteredOptions;
return [...filteredOptions].sort((a, b) => {
const aSelected = internalValue.includes(a.value);
const bSelected = internalValue.includes(b.value);
const aSelected = selectedValueSet.has(a.value);
const bSelected = selectedValueSet.has(b.value);
if (aSelected && !bSelected) return -1;
if (!aSelected && bSelected) return 1;
return a.label.localeCompare(b.label);
});
}, [filteredOptions, internalValue]);
}, [filteredOptions, selectedValueSet, selectOptions.length]);
// Memoize selected values display
const selectedValues = useMemo(() => {
// Use a map for looking up options by value for better performance
const optionsMap = new Map(selectOptions.map(opt => [opt.value, opt]));
return internalValue.map(v => {
const option = selectOptions.find(opt => String(opt.value) === String(v));
const option = optionsMap.get(v);
return {
value: v,
label: option ? option.label : String(v)
@@ -141,7 +309,6 @@ const MultiInputCell = <T extends string>({
return [...prev, selectedValue];
}
});
setSearchQuery("");
}, []);
// Handle focus
@@ -211,28 +378,6 @@ const MultiInputCell = <T extends string>({
// Create a reference to the container element
const containerRef = useRef<HTMLDivElement>(null);
// Use a layout effect to force the width after rendering
useLayoutEffect(() => {
if (containerRef.current) {
const container = containerRef.current;
// Force direct style properties using the DOM API - simplified approach
container.style.width = `${cellWidth}px`;
container.style.minWidth = `${cellWidth}px`;
container.style.maxWidth = `${cellWidth}px`;
// Apply to the button element as well
const button = container.querySelector('button');
if (button) {
// Cast to HTMLElement to access style property
const htmlButton = button as HTMLElement;
htmlButton.style.width = `${cellWidth}px`;
htmlButton.style.minWidth = `${cellWidth}px`;
htmlButton.style.maxWidth = `${cellWidth}px`;
}
}
}, [cellWidth]);
// Create a key-value map for inline styles with fixed width - simplified
const fixedWidth = useMemo(() => ({
width: `${cellWidth}px`,
@@ -241,6 +386,32 @@ const MultiInputCell = <T extends string>({
boxSizing: 'border-box' as const,
}), [cellWidth]);
// Use layout effect more efficiently - only for the button element
// since the container already uses inline styles
useLayoutEffect(() => {
// Skip if no width specified
if (!cellWidth) return;
// Cache previous width to avoid unnecessary DOM updates
const prevWidth = containerRef.current?.getAttribute('data-prev-width');
// Only update if width changed
if (prevWidth !== String(cellWidth) && containerRef.current) {
// Store new width for next comparison
containerRef.current.setAttribute('data-prev-width', String(cellWidth));
// Only manipulate the button element directly since we can't
// reliably style it with CSS in all cases
const button = containerRef.current.querySelector('button');
if (button) {
const htmlButton = button as HTMLElement;
htmlButton.style.width = `${cellWidth}px`;
htmlButton.style.minWidth = `${cellWidth}px`;
htmlButton.style.maxWidth = `${cellWidth}px`;
}
}
}, [cellWidth]);
return (
<div
ref={containerRef}
@@ -305,30 +476,22 @@ const MultiInputCell = <T extends string>({
onValueChange={setSearchQuery}
/>
<CommandList
className="max-h-[200px] overflow-y-auto"
className="overflow-hidden"
ref={commandListRef}
onWheel={handleWheel}
>
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup>
{sortedOptions.map((option) => (
<CommandItem
key={option.value}
value={option.value}
{sortedOptions.length > 0 ? (
<VirtualizedOptions
options={sortedOptions}
selectedValues={selectedValueSet}
onSelect={handleSelect}
className="flex w-full"
>
<div className="flex items-center w-full overflow-hidden">
<Check
className={cn(
"mr-2 h-4 w-4 flex-shrink-0",
internalValue.includes(option.value) ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate w-full">{option.label}</span>
</div>
</CommandItem>
))}
maxHeight={200}
/>
) : (
<div className="py-6 text-center text-sm">No options match your search</div>
)}
</CommandGroup>
</CommandList>
</Command>
@@ -346,28 +509,6 @@ const MultiInputCell = <T extends string>({
// Create a reference to the container element
const containerRef = useRef<HTMLDivElement>(null);
// Use a layout effect to force the width after rendering
useLayoutEffect(() => {
if (containerRef.current) {
const container = containerRef.current;
// Force direct style properties using the DOM API - simplified approach
container.style.width = `${cellWidth}px`;
container.style.minWidth = `${cellWidth}px`;
container.style.maxWidth = `${cellWidth}px`;
// Apply to the button element as well
const button = container.querySelector('button');
if (button) {
// Cast to HTMLElement to access style property
const htmlButton = button as HTMLElement;
htmlButton.style.width = `${cellWidth}px`;
htmlButton.style.minWidth = `${cellWidth}px`;
htmlButton.style.maxWidth = `${cellWidth}px`;
}
}
}, [cellWidth]);
// Create a key-value map for inline styles with fixed width - simplified
const fixedWidth = useMemo(() => ({
width: `${cellWidth}px`,
@@ -376,6 +517,32 @@ const MultiInputCell = <T extends string>({
boxSizing: 'border-box' as const,
}), [cellWidth]);
// Use layout effect more efficiently - only for the button element
// since the container already uses inline styles
useLayoutEffect(() => {
// Skip if no width specified
if (!cellWidth) return;
// Cache previous width to avoid unnecessary DOM updates
const prevWidth = containerRef.current?.getAttribute('data-prev-width');
// Only update if width changed
if (prevWidth !== String(cellWidth) && containerRef.current) {
// Store new width for next comparison
containerRef.current.setAttribute('data-prev-width', String(cellWidth));
// Only manipulate the button element directly since we can't
// reliably style it with CSS in all cases
const button = containerRef.current.querySelector('button');
if (button) {
const htmlButton = button as HTMLElement;
htmlButton.style.width = `${cellWidth}px`;
htmlButton.style.minWidth = `${cellWidth}px`;
htmlButton.style.maxWidth = `${cellWidth}px`;
}
}
}, [cellWidth]);
return (
<div
ref={containerRef}
@@ -460,4 +627,51 @@ const MultiInputCell = <T extends string>({
MultiInputCell.displayName = 'MultiInputCell';
export default React.memo(MultiInputCell);
export default React.memo(MultiInputCell, (prev, next) => {
// Quick check for reference equality of simple props
if (prev.hasErrors !== next.hasErrors ||
prev.disabled !== next.disabled ||
prev.isMultiline !== next.isMultiline ||
prev.isPrice !== next.isPrice ||
prev.separator !== next.separator) {
return false;
}
// Array comparison for value
if (Array.isArray(prev.value) && Array.isArray(next.value)) {
if (prev.value.length !== next.value.length) return false;
// Check each item in the array - optimize for large arrays
if (prev.value.length > 50) {
// For large arrays, JSON stringify is actually faster than iterating
return JSON.stringify(prev.value) === JSON.stringify(next.value);
}
// For smaller arrays, iterative comparison is more efficient
for (let i = 0; i < prev.value.length; i++) {
if (prev.value[i] !== next.value[i]) return false;
}
} else if (prev.value !== next.value) {
return false;
}
// Only do a full options comparison if they are different references and small arrays
if (prev.options !== next.options) {
if (!prev.options || !next.options) return false;
if (prev.options.length !== next.options.length) return false;
// For large option lists, just check reference equality
if (prev.options.length > 100) return false;
// For smaller lists, check if any values differ
for (let i = 0; i < prev.options.length; i++) {
const prevOpt = prev.options[i];
const nextOpt = next.options[i];
if (prevOpt.value !== nextOpt.value || prevOpt.label !== nextOpt.label) {
return false;
}
}
}
return true;
});

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useCallback, useMemo } from 'react'
import { useState, useRef, useCallback, useMemo, useEffect } from 'react'
import { Field } from '../../../../types'
import { Check, ChevronsUpDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
@@ -47,7 +47,7 @@ const SelectCell = <T extends string>({
const [isProcessing, setIsProcessing] = useState(false);
// Update internal value when prop value changes
React.useEffect(() => {
useEffect(() => {
setInternalValue(value);
// When the value prop changes, it means validation is complete
setIsProcessing(false);
@@ -55,32 +55,51 @@ const SelectCell = <T extends string>({
// Memoize options processing to avoid recalculation on every render
const selectOptions = useMemo(() => {
// Ensure we always have an array of options with the correct shape
const fieldType = field.fieldType;
const fieldOptions = fieldType &&
(fieldType.type === 'select' || fieldType.type === 'multi-select') &&
(fieldType as any).options ?
(fieldType as any).options :
[];
// Always ensure selectOptions is a valid array with at least a default option
const processedOptions = (options || fieldOptions || []).map((option: any) => ({
label: option.label || String(option.value),
value: String(option.value)
}));
if (processedOptions.length === 0) {
processedOptions.push({ label: 'No options available', value: '' });
// Fast path check - if we have raw options, just use those
if (options && options.length > 0) {
// Check if options already have the correct structure to avoid mapping
if (typeof options[0] === 'object' && 'label' in options[0] && 'value' in options[0]) {
return options as SelectOption[];
}
// Optimize mapping to only convert what's needed
return options.map((option: any) => ({
label: option.label || String(option.value || option),
value: String(option.value || option)
}));
}
return processedOptions;
// Fall back to field options if no direct options provided
const fieldType = field.fieldType;
if (fieldType &&
(fieldType.type === 'select' || fieldType.type === 'multi-select') &&
(fieldType as any).options) {
const fieldOptions = (fieldType as any).options;
// Check if fieldOptions already have the correct structure
if (fieldOptions.length > 0 && typeof fieldOptions[0] === 'object' &&
'label' in fieldOptions[0] && 'value' in fieldOptions[0]) {
return fieldOptions as SelectOption[];
}
return fieldOptions.map((option: any) => ({
label: option.label || String(option.value || option),
value: String(option.value || option)
}));
}
// Return default empty option if no options available
return [{ label: 'No options available', value: '' }];
}, [field.fieldType, options]);
// Memoize display value to avoid recalculation on every render
const displayValue = useMemo(() => {
return internalValue ?
selectOptions.find((option: SelectOption) => String(option.value) === String(internalValue))?.label || String(internalValue) :
'Select...';
if (!internalValue) return 'Select...';
// Fast path: direct lookup by value using find
const stringValue = String(internalValue);
const found = selectOptions.find((option: SelectOption) => String(option.value) === stringValue);
return found ? found.label : stringValue;
}, [internalValue, selectOptions]);
// Handle wheel scroll in dropdown - optimized with passive event
@@ -112,27 +131,9 @@ const SelectCell = <T extends string>({
}, 0);
}, [onChange, onEndEdit]);
// Memoize the command items to avoid recreating them on every render
const commandItems = useMemo(() => {
return selectOptions.map((option: SelectOption) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => handleSelect(option.value)}
className="cursor-pointer"
>
{option.label}
{String(option.value) === String(internalValue) && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
));
}, [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;
const displayText = displayValue;
return (
<div className={cn(
@@ -184,7 +185,7 @@ const SelectCell = <T extends string>({
align="start"
sideOffset={4}
>
<Command shouldFilter={false}>
<Command shouldFilter={true}>
<CommandInput
placeholder="Search..."
className="h-9"
@@ -196,7 +197,19 @@ const SelectCell = <T extends string>({
>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{commandItems}
{selectOptions.map((option: SelectOption) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => handleSelect(option.value)}
className="cursor-pointer"
>
{option.label}
{String(option.value) === String(internalValue) && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
@@ -208,10 +221,15 @@ const SelectCell = <T extends string>({
// 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
);
if (prev.value !== next.value) return false;
if (prev.hasErrors !== next.hasErrors) return false;
if (prev.disabled !== next.disabled) return false;
// Only check options array for reference equality - we're handling deep comparison internally
if (prev.options !== next.options &&
(prev.options.length !== next.options.length)) {
return false;
}
return true;
});

View File

@@ -15,6 +15,14 @@ interface InfoWithSource {
source: ErrorSources
}
// Shared utility function for checking empty values - defined once to avoid duplication
const isEmpty = (value: any): boolean =>
value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0);
export const useValidation = <T extends string>(
fields: Fields<T>,
rowHook?: RowHook<T>,
@@ -32,15 +40,8 @@ export const useValidation = <T extends string>(
field.validations.forEach(validation => {
switch (validation.rule) {
case 'required':
// More granular check for empty values
const isEmpty =
value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' && Object.keys(value).length === 0);
if (isEmpty) {
// Use the shared isEmpty function
if (isEmpty(value)) {
errors.push({
message: validation.errorMessage || 'This field is required',
level: validation.level || 'error'
@@ -82,13 +83,7 @@ export const useValidation = <T extends string>(
// Run field-level validations
const fieldErrors: Record<string, ValidationError[]> = {}
// Helper function to check if a value is empty
const isEmpty = (value: any): boolean =>
value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0);
// Use the shared isEmpty function
fields.forEach(field => {
const value = row[String(field.key) as keyof typeof row]
@@ -230,7 +225,7 @@ export const useValidation = <T extends string>(
const value = String(row[String(key) as keyof typeof row] || '')
// Skip empty values if allowed
if (allowEmpty && (value === '' || value === undefined || value === null)) {
if (allowEmpty && isEmpty(value)) {
return
}
@@ -265,13 +260,7 @@ export const useValidation = <T extends string>(
// Run complete validation
const validateData = useCallback(async (data: RowData<T>[]) => {
// Helper function to check if a value is empty
const isEmpty = (value: any): boolean =>
value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0);
// Use the shared isEmpty function
// Step 1: Run field and row validation
const rowValidations = await Promise.all(

View File

@@ -225,8 +225,49 @@ export const useValidationState = <T extends string>({
const flushPendingUpdates = useCallback(() => {
const updates = pendingUpdatesRef.current;
// Use a single setState call for validation errors if possible
if (updates.errors.size > 0) {
setValidationErrors(prev => {
// Create a new Map only if we're modifying it
const needsUpdate = Array.from(updates.errors.entries()).some(([rowIndex, errors]) => {
const prevErrors = prev.get(rowIndex);
const hasErrors = Object.keys(errors).length > 0;
// Check if we need to update this row's errors
if (!prevErrors && hasErrors) return true;
if (prevErrors && !hasErrors) return true;
if (!prevErrors && !hasErrors) return false;
// Check if the error objects are different
return Object.keys(errors).some(key => {
const prevError = prevErrors?.[key];
const nextError = errors[key];
if (!prevError && nextError) return true;
if (prevError && !nextError) return true;
if (!prevError && !nextError) return false;
// Compare the arrays if both exist
if (Array.isArray(prevError) && Array.isArray(nextError)) {
if (prevError.length !== nextError.length) return true;
// Deep comparison of error objects
return prevError.some((err, i) => {
const nextErr = nextError[i];
return err.message !== nextErr.message ||
err.level !== nextErr.level ||
err.source !== nextErr.source;
});
}
return true;
});
});
// If no real changes, return the same state object
if (!needsUpdate) return prev;
// Otherwise create a new Map with the updates
const newErrors = new Map(prev);
updates.errors.forEach((errors, rowIndex) => {
if (Object.keys(errors).length === 0) {
@@ -235,30 +276,75 @@ export const useValidationState = <T extends string>({
newErrors.set(rowIndex, errors);
}
});
return newErrors;
});
// Clear the updates
updates.errors = new Map();
}
// Use a single setState call for row validation statuses
if (updates.statuses.size > 0) {
setRowValidationStatus(prev => {
// Check if we need to update
const needsUpdate = Array.from(updates.statuses.entries()).some(([rowIndex, status]) => {
return prev.get(rowIndex) !== status;
});
// If no real changes, return the same state object
if (!needsUpdate) return prev;
// Create a new Map with updates
const newStatuses = new Map(prev);
updates.statuses.forEach((status, rowIndex) => {
newStatuses.set(rowIndex, status);
});
return newStatuses;
});
// Clear the updates
updates.statuses = new Map();
}
// Use a single setState call for data updates
if (updates.data.length > 0) {
setData(prev => {
const newData = [...prev];
updates.data.forEach((row, index) => {
newData[index] = row;
// Find non-empty items
const dataUpdates = updates.data.filter(item => item !== undefined);
if (dataUpdates.length > 0) {
setData(prev => {
// Check if we actually need to update
const needsUpdate = dataUpdates.some((row, index) => {
const oldRow = prev[index];
if (!oldRow) return true;
// Compare the rows
return Object.keys(row).some(key => {
// Skip meta fields that don't affect rendering
if (key.startsWith('__') && key !== '__template') return false;
return oldRow[key] !== row[key];
});
});
// If no actual changes, return the same array
if (!needsUpdate) return prev;
// Create a new array with the updates
const newData = [...prev];
dataUpdates.forEach((row, index) => {
if (index < newData.length) {
newData[index] = row;
}
});
return newData;
});
return newData;
});
}
// Clear the updates
updates.data = [];
}
}, []);
@@ -289,39 +375,62 @@ export const useValidationState = <T extends string>({
// Update validateUniqueItemNumbers to use batch updates
const validateUniqueItemNumbers = useCallback(async () => {
const duplicates = new Map<string, number[]>();
const itemNumberMap = new Map<string, number>();
console.log('Validating unique item numbers');
// Skip if no data
if (!data.length) return;
// Use a more efficient Map to track duplicates
const itemNumberMap = new Map<string, number[]>();
// Initialize batch updates
const errors = new Map<number, Record<string, ErrorType[]>>();
// Single pass through data to identify all item numbers
data.forEach((row, index) => {
const itemNumber = row.item_number?.toString();
if (itemNumber) {
if (itemNumberMap.has(itemNumber)) {
const existingIndex = itemNumberMap.get(itemNumber)!;
if (!duplicates.has(itemNumber)) {
duplicates.set(itemNumber, [existingIndex]);
}
duplicates.get(itemNumber)!.push(index);
} else {
itemNumberMap.set(itemNumber, index);
}
// Get or initialize the array of indices for this item number
const indices = itemNumberMap.get(itemNumber) || [];
indices.push(index);
itemNumberMap.set(itemNumber, indices);
}
});
duplicates.forEach((rowIndices, itemNumber) => {
rowIndices.forEach(rowIndex => {
const errors = {
item_number: [{
message: `Duplicate item number: ${itemNumber}`,
level: 'error',
source: 'validation'
}]
// Process duplicates more efficiently
itemNumberMap.forEach((indices, itemNumber) => {
// Only process if there are duplicates
if (indices.length > 1) {
const errorObj = {
message: `Duplicate item number: ${itemNumber}`,
level: 'error',
source: 'validation'
};
queueUpdate(rowIndex, { errors });
});
// Add error to each row with this item number
indices.forEach(rowIndex => {
const rowErrors = errors.get(rowIndex) || {};
rowErrors['item_number'] = [errorObj];
errors.set(rowIndex, rowErrors);
});
}
});
debouncedFlushUpdates();
}, [data, queueUpdate, debouncedFlushUpdates]);
// Apply batch updates
if (errors.size > 0) {
setValidationErrors(prev => {
const newMap = new Map(prev);
errors.forEach((rowErrors, rowIndex) => {
// Preserve existing errors for other fields
const existingErrors = newMap.get(rowIndex) || {};
newMap.set(rowIndex, { ...existingErrors, ...rowErrors });
});
return newMap;
});
}
console.log('Unique item number validation complete');
}, [data]);
// Fetch product by UPC from API - optimized with proper error handling and types
const fetchProductByUpc = useCallback(async (supplier: string, upc: string): Promise<ValidationResult> => {
@@ -664,8 +773,14 @@ export const useValidationState = <T extends string>({
const fieldErrors: Record<string, ErrorType[]> = {};
let hasErrors = false;
// Get current errors for comparison
const currentErrors = validationErrors.get(rowIndex) || {};
// Track if row has changes to original values
const originalRow = row.__original || {};
const changedFields = row.__changes || {};
// Use a more efficient approach - only validate fields that need validation
// This includes required fields and fields with values
fields.forEach(field => {
if (field.disabled) return;
@@ -678,15 +793,32 @@ export const useValidationState = <T extends string>({
return;
}
// Validate the field
const errors = validateField(value, field as Field<T>);
if (errors.length > 0) {
fieldErrors[key] = errors;
hasErrors = true;
// Only validate if:
// 1. Field has changed (if we have change tracking)
// 2. No prior validation exists
// 3. This is a special field (supplier/company)
const hasChanged = changedFields[key] ||
!currentErrors[key] ||
key === 'supplier' ||
key === 'company';
if (hasChanged) {
// Validate the field
const errors = validateField(value, field as Field<T>);
if (errors.length > 0) {
fieldErrors[key] = errors;
hasErrors = true;
}
} else {
// Keep existing errors if field hasn't changed
if (currentErrors[key] && currentErrors[key].length > 0) {
fieldErrors[key] = currentErrors[key];
hasErrors = true;
}
}
});
// Special validation for supplier and company
// Special validation for supplier and company - always validate these
if (!row.supplier) {
fieldErrors['supplier'] = [{
message: 'Supplier is required',
@@ -707,7 +839,11 @@ export const useValidationState = <T extends string>({
// Update validation errors for this row
setValidationErrors(prev => {
const updated = new Map(prev);
updated.set(rowIndex, fieldErrors);
if (Object.keys(fieldErrors).length > 0) {
updated.set(rowIndex, fieldErrors);
} else {
updated.delete(rowIndex);
}
return updated;
});
@@ -717,7 +853,7 @@ export const useValidationState = <T extends string>({
updated.set(rowIndex, hasErrors ? 'error' : 'validated');
return updated;
});
}, [data, fields, validateField]);
}, [data, fields, validateField, validationErrors]);
// Update a row's field value
const updateRow = useCallback((rowIndex: number, key: T, value: any) => {
@@ -926,7 +1062,7 @@ export const useValidationState = <T extends string>({
}
}, [data, rowSelection, setData]);
// Apply template to rows
// Apply template to rows - optimized version
const applyTemplate = useCallback((templateId: string, rowIndexes: number[]) => {
const template = templates.find(t => t.id.toString() === templateId);
@@ -936,7 +1072,6 @@ export const useValidationState = <T extends string>({
}
console.log(`Applying template ${templateId} to rows:`, rowIndexes);
console.log(`Template data:`, template);
// Validate row indexes
const validRowIndexes = rowIndexes.filter(index =>
@@ -949,11 +1084,6 @@ export const useValidationState = <T extends string>({
return;
}
if (validRowIndexes.length !== rowIndexes.length) {
console.warn('Some row indexes were invalid and will be skipped:',
rowIndexes.filter(idx => !validRowIndexes.includes(idx)));
}
// Set the template application flag
isApplyingTemplateRef.current = true;
@@ -963,59 +1093,58 @@ export const useValidationState = <T extends string>({
top: window.scrollY
};
// Track updated rows for UPC validation
const updatedRows: number[] = [];
// Create a copy of the data to track updates
// Create a copy of data and process all rows at once to minimize state updates
const newData = [...data];
const batchErrors = new Map<number, Record<string, ErrorType[]>>();
const batchStatuses = new Map<number, 'pending' | 'validating' | 'validated' | 'error'>();
// Extract template fields once outside the loop
const templateFields = Object.entries(template).filter(([key]) =>
!['id', '__errors', '__meta', '__template', '__original', '__corrected', '__changes'].includes(key)
);
// Apply template to each valid row
validRowIndexes.forEach(index => {
// Create a new row with template values
const originalRow = newData[index];
console.log(`Applying to row at index ${index}:`, originalRow);
const updatedRow = { ...originalRow };
const updatedRow = { ...originalRow } as Record<string, any>;
// Clear existing errors
delete updatedRow.__errors;
// Apply template fields (excluding metadata fields)
Object.entries(template).forEach(([key, value]) => {
if (!['id', '__errors', '__meta', '__template', '__original', '__corrected', '__changes'].includes(key)) {
(updatedRow as any)[key] = value;
}
});
for (const [key, value] of templateFields) {
updatedRow[key] = value;
}
// Mark the row as using this template
updatedRow.__template = templateId;
// Update the row in the data array
newData[index] = updatedRow;
newData[index] = updatedRow as RowData<T>;
// Track which rows were updated
updatedRows.push(index);
console.log(`Row ${index} updated:`, updatedRow);
// Clear validation errors and mark as validated
batchErrors.set(index, {});
batchStatuses.set(index, 'validated');
});
// Update all data at once
// Perform a single update for all rows
setData(newData);
// Clear validation errors and status for affected rows
// Update all validation errors and statuses at once
setValidationErrors(prev => {
const newErrors = new Map(prev);
validRowIndexes.forEach(index => {
newErrors.delete(index);
});
for (const [rowIndex, errors] of batchErrors.entries()) {
newErrors.set(rowIndex, errors);
}
return newErrors;
});
setRowValidationStatus(prev => {
const newStatus = new Map(prev);
validRowIndexes.forEach(index => {
newStatus.set(index, 'validated'); // Mark as validated immediately
});
for (const [rowIndex, status] of batchStatuses.entries()) {
newStatus.set(rowIndex, status);
}
return newStatus;
});
@@ -1031,32 +1160,56 @@ export const useValidationState = <T extends string>({
toast.success(`Template applied to ${validRowIndexes.length} rows`);
}
// Schedule UPC validation with a delay
setTimeout(() => {
// Process rows in sequence to ensure validation state is consistent
const processRows = async () => {
for (const rowIndex of updatedRows) {
// Get the current row data after template application
const currentRow = newData[rowIndex];
// Check which rows need UPC validation
const upcValidationRows = validRowIndexes.filter(rowIndex => {
const row = newData[rowIndex];
return row && row.upc && row.supplier;
});
// If there are rows needing UPC validation, process them
if (upcValidationRows.length > 0) {
// Batch UPC validation for better performance
setTimeout(() => {
// Process in batches to avoid overwhelming API
const processUpcValidations = async () => {
const BATCH_SIZE = 5;
// Check if UPC validation is needed
if (currentRow && currentRow.upc && currentRow.supplier) {
await validateUpc(rowIndex, String(currentRow.supplier), String(currentRow.upc));
// Sort by upc for better caching
upcValidationRows.sort((a, b) => {
const aUpc = String(newData[a].upc || '');
const bUpc = String(newData[b].upc || '');
return aUpc.localeCompare(bUpc);
});
// Process in batches to avoid hammering the API
for (let i = 0; i < upcValidationRows.length; i += BATCH_SIZE) {
const batch = upcValidationRows.slice(i, i + BATCH_SIZE);
// Process this batch in parallel
await Promise.all(batch.map(async (rowIndex) => {
const row = newData[rowIndex];
if (row && row.upc && row.supplier) {
await validateUpc(rowIndex, String(row.supplier), String(row.upc));
}
}));
// Add delay between batches to reduce server load
if (i + BATCH_SIZE < upcValidationRows.length) {
await new Promise(r => setTimeout(r, 300));
}
}
// Small delay between rows to prevent overwhelming the UI
if (updatedRows.length > 1) {
await new Promise(resolve => setTimeout(resolve, 50));
}
}
// Reset template application flag
isApplyingTemplateRef.current = false;
};
// Reset the template application flag after all processing is done
isApplyingTemplateRef.current = false;
};
// Start processing rows
processRows();
}, 500);
// Start processing
processUpcValidations();
}, 100);
} else {
// No UPC validation needed, reset flag immediately
isApplyingTemplateRef.current = false;
}
}, [data, templates, validateUpc, setData, setValidationErrors, setRowValidationStatus]);
// Apply template to selected rows
@@ -1234,24 +1387,38 @@ export const useValidationState = <T extends string>({
return;
}
// Create a copy for data modifications
const newData = [...data];
const initialStatus = new Map();
const initialErrors = new Map();
// Use Maps for better performance with large datasets
const batchErrors = new Map<number, Record<string, ErrorType[]>>();
const batchStatuses = new Map<number, 'pending' | 'validating' | 'validated' | 'error'>();
console.log(`Validating ${data.length} rows`);
// Process in batches to avoid blocking the UI
const BATCH_SIZE = 100; // Increase batch size for better performance
const BATCH_SIZE = Math.min(100, Math.max(20, Math.floor(data.length / 10))); // Adaptive batch size
const totalBatches = Math.ceil(data.length / BATCH_SIZE);
let currentBatch = 0;
let totalBatches = Math.ceil(data.length / BATCH_SIZE);
// Pre-cache field validations
const requiredFields = fields.filter(f => f.validations?.some(v => v.rule === 'required'));
const requiredFieldKeys = new Set(requiredFields.map(f => String(f.key)));
// Pre-process the supplier and company fields checks
const hasSupplierField = fields.some(field => String(field.key) === 'supplier');
const hasCompanyField = fields.some(field => String(field.key) === 'company');
const processBatch = () => {
const startIdx = currentBatch * BATCH_SIZE;
const endIdx = Math.min(startIdx + BATCH_SIZE, data.length);
// Create a batch of validation promises
// Start validation time measurement for this batch
const batchStartTime = performance.now();
// Create validation promises for all rows in the batch
const batchPromises = [];
// Prepare a single batch processor for all rows
for (let rowIndex = startIdx; rowIndex < endIdx; rowIndex++) {
batchPromises.push(
new Promise<void>(resolve => {
@@ -1274,36 +1441,52 @@ export const useValidationState = <T extends string>({
} as RowData<T>;
}
// Process price fields to strip dollar signs - use the cleanPriceFields function
// Process price fields efficiently - use a single check for both fields
const rowAsRecord = row as Record<string, any>;
if ((typeof rowAsRecord.msrp === 'string' && rowAsRecord.msrp.includes('$')) ||
(typeof rowAsRecord.cost_each === 'string' && rowAsRecord.cost_each.includes('$'))) {
// Clean just this row
const cleanedRow = cleanPriceFields([row])[0];
newData[rowIndex] = cleanedRow;
const mSrpNeedsProcessing = typeof rowAsRecord.msrp === 'string' && rowAsRecord.msrp.includes('$');
const costEachNeedsProcessing = typeof rowAsRecord.cost_each === 'string' && rowAsRecord.cost_each.includes('$');
if (mSrpNeedsProcessing || costEachNeedsProcessing) {
// Create a clean copy only if needed
const cleanedRow = {...row} as Record<string, any>;
if (mSrpNeedsProcessing) {
const msrpValue = rowAsRecord.msrp.replace(/[$,]/g, '');
const numValue = parseFloat(msrpValue);
cleanedRow.msrp = !isNaN(numValue) ? numValue.toFixed(2) : msrpValue;
}
if (costEachNeedsProcessing) {
const costValue = rowAsRecord.cost_each.replace(/[$,]/g, '');
const numValue = parseFloat(costValue);
cleanedRow.cost_each = !isNaN(numValue) ? numValue.toFixed(2) : costValue;
}
newData[rowIndex] = cleanedRow as RowData<T>;
}
// Only validate required fields and fields with values
fields.forEach(field => {
if (field.disabled) return;
// Only validate required fields for efficiency
for (const field of requiredFields) {
const key = String(field.key);
const value = row[key as keyof typeof row];
// Skip validation for empty non-required fields
const isRequired = field.validations?.some(v => v.rule === 'required');
if (!isRequired && (value === undefined || value === null || value === '')) {
return;
}
const errors = validateField(value, field as Field<T>);
if (errors.length > 0) {
fieldErrors[key] = errors;
// Skip non-required empty fields
if (value === undefined || value === null || value === '' ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' && value !== null && Object.keys(value).length === 0)) {
// Add error for empty required fields
fieldErrors[key] = [{
message: field.validations?.find(v => v.rule === 'required')?.errorMessage || 'This field is required',
level: 'error',
source: 'required'
}];
hasErrors = true;
}
});
}
// Special validation for supplier and company
if (!row.supplier) {
if (hasSupplierField && !row.supplier) {
fieldErrors['supplier'] = [{
message: 'Supplier is required',
level: 'error',
@@ -1311,7 +1494,8 @@ export const useValidationState = <T extends string>({
}];
hasErrors = true;
}
if (!row.company) {
if (hasCompanyField && !row.company) {
fieldErrors['company'] = [{
message: 'Company is required',
level: 'error',
@@ -1320,11 +1504,13 @@ export const useValidationState = <T extends string>({
hasErrors = true;
}
// Update validation errors for this row
initialErrors.set(rowIndex, fieldErrors);
// Only add errors if there are any
if (Object.keys(fieldErrors).length > 0) {
batchErrors.set(rowIndex, fieldErrors);
}
// Update row validation status
initialStatus.set(rowIndex, hasErrors ? 'error' : 'validated');
batchStatuses.set(rowIndex, hasErrors ? 'error' : 'validated');
resolve();
})
@@ -1333,30 +1519,43 @@ export const useValidationState = <T extends string>({
// Process all promises in the batch
Promise.all(batchPromises).then(() => {
// Update state for this batch
setValidationErrors(prev => {
const newMap = new Map(prev);
initialErrors.forEach((errors, rowIndex) => {
newMap.set(rowIndex, errors);
});
return newMap;
});
// Measure batch completion time
const batchEndTime = performance.now();
const processingTime = batchEndTime - batchStartTime;
setRowValidationStatus(prev => {
const newMap = new Map(prev);
initialStatus.forEach((status, rowIndex) => {
newMap.set(rowIndex, status);
// Update UI state for this batch more efficiently
if (batchErrors.size > 0) {
setValidationErrors(prev => {
const newMap = new Map(prev);
for (const [rowIndex, errors] of batchErrors.entries()) {
newMap.set(rowIndex, errors);
}
return newMap;
});
return newMap;
});
}
if (batchStatuses.size > 0) {
setRowValidationStatus(prev => {
const newMap = new Map(prev);
for (const [rowIndex, status] of batchStatuses.entries()) {
newMap.set(rowIndex, status);
}
return newMap;
});
}
// Move to the next batch or finish
currentBatch++;
// Log progress
console.log(`Batch ${currentBatch}/${totalBatches} completed in ${processingTime.toFixed(2)}ms`);
if (currentBatch < totalBatches) {
// Schedule the next batch with a small delay to allow UI updates
setTimeout(processBatch, 10);
// Adaptive timeout based on processing time
const nextDelay = Math.min(50, Math.max(5, Math.ceil(processingTime / 10)));
setTimeout(processBatch, nextDelay);
} else {
// All batches processed, update the data
// All batches processed, update the data once
setData(newData);
console.log('Basic validation complete');
initialValidationDoneRef.current = true;