More validation table optimizations + create doc to track remaining fixes
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user