From 1aee18a02589b75d93186e195511ecded93aa82c Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 11 Mar 2025 16:21:17 -0400 Subject: [PATCH] More validation table optimizations + create doc to track remaining fixes --- docs/validate-table-changes.md | 303 +++++++++++ .../components/ValidationCell.tsx | 266 ++++++---- .../components/ValidationTable.tsx | 183 +++++-- .../components/cells/InputCell.tsx | 124 +++-- .../components/cells/MultiInputCell.tsx | 382 +++++++++++--- .../components/cells/SelectCell.tsx | 116 +++-- .../ValidationStepNew/hooks/useValidation.tsx | 37 +- .../hooks/useValidationState.tsx | 481 +++++++++++++----- 8 files changed, 1425 insertions(+), 467 deletions(-) create mode 100644 docs/validate-table-changes.md diff --git a/docs/validate-table-changes.md b/docs/validate-table-changes.md new file mode 100644 index 0000000..55890ab --- /dev/null +++ b/docs/validate-table-changes.md @@ -0,0 +1,303 @@ +# Current Issues to Address +1. The red row background should go away when all cells in the row are valid and all required cells are populated +2. Columns alignment with header is slightly off, gets worse the further right you go +3. The copy down button is in the way of the validation error icon and the select open trigger - all three need to be in unique locations +4. Validation isn't happening beyond checking if a cell is required or not - needs to respect rules in import.tsx + * Red cell outline if cell is required and it's empty + * Red outline + alert circle icon with tooltip if cell is NOT empty and isn't valid +5. Description column needs to have an expanded view of some sort, maybe a popover to allow for easier editing + * Don't distort table to make it happen +6. Need to ensure all cell's contents don't overflow the input (truncate). COO does this currently, probably more +7. The template select cell is expanding, needs to be fixed size and truncate +8. When you enter a value in 2+ cells before validation finishes, contents from all edited cells get erased when validation finishes +9. Import dialog state not fully reset when closing? (validate data step appears scrolled to the middle of the table where I left it) +10. UPC column doesn't need to show loading state when Item Number is being processed, only show on item number column +11. Copy down needs to show a loading state on the cells that it will copy to +12. Shipping restrictions/tax category should default to ID 0 if we didn't get it elsewhere +13. Header row should be sticky (both up/down and left/right) +14. Need a way to scroll around table if user doesn't have mouse wheel for left/right +15. Need to remove all artificial virtualization, batching, artificial delays, and caching. Adds too much complexity and data set is not ever large enough for this to be helpful. Keep actual performance optimizations. + +## Do NOT change or edit +* Anything related to AI validation +* Anything about how templates or UPC validation work (only focus on specific issues described above) +* Anything outside of the ValidationStepNew folder + +--------- + +# Validation Step Components Overview + +## Core Components + +### ValidationContainer +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx` +- Main wrapper component for the validation step +- Manages global state and coordinates between subcomponents +- Handles navigation events (next, back) +- Manages template application and validation state +- Coordinates UPC validation and product line loading +- Manages row selection and filtering +- Contains cache management for UPC validation results +- Maintains item number references separate from main data + +### ValidationTable +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx` +- Handles data display and column configuration +- Uses TanStack Table for core functionality +- Features: + - Sticky header (both vertical and horizontal) - currently doesn't work properly + - Row selection with checkboxes + - Template selection column + - Dynamic column widths based on field types - specified in import.tsx component + - Copy down functionality for cell values + - Error highlighting for rows and cells + - Loading states for cells being validated + +### ValidationCell +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx` +- Base cell component that renders different cell types based on field configuration +- Handles error display with tooltips +- Manages copy down button visibility +- Supports loading states during validation +- Cell Types: + 1. InputCell: For single-value text input + 2. SelectCell: For dropdown selection + 3. MultiInputCell: For multiple value inputs + 4. Template selection cells with SearchableTemplateSelect component + +### SearchableTemplateSelect +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SearchableTemplateSelect.tsx` +- Advanced template selection component with search functionality +- Features: + - Real-time search filtering of templates + - Customizable display text for templates + - Support for default brand selection + - Accessible popover interface + - Keyboard navigation support + - Custom styling through className props + - Scroll event handling for nested scrollable areas + +### TemplateManager +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/TemplateManager.tsx` +- Comprehensive template management interface +- Features: + - Template selection with search functionality + - Save template dialog with name and type inputs + - Batch template application to selected rows + - Template count tracking + - Toast notifications for user feedback + - Dialog-based interface for template operations + +### AiValidationDialogs +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/AiValidationDialogs.tsx` +- Manages AI-assisted validation dialogs and interactions + +### SaveTemplateDialog +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SaveTemplateDialog.tsx` +- Dialog component for saving new templates + +## Cell Components + +### InputCell +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx` +- Handles single value text input +- Features: + - Inline/edit mode switching + - Multiline support + - Price formatting + - Error state display + - Loading state during validation + - Width constraints + - Automated cleanPriceFields processing for "$" formatting + +### SelectCell +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx` +- Handles dropdown selection +- Features: + - Searchable dropdown + - Custom option rendering + - Error state display + - Loading state during validation + - Width constraints + - Disabled state support + - Deferred search query handling for performance + +### MultiInputCell +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiInputCell.tsx` +- Handles multiple value inputs +- Features: + - Comma-separated input support + - Multi-select dropdown for predefined options + - Custom separators + - Badge display for selected count + - Truncation for long values + - Width constraints + - Price formatting support + - Internal state management to avoid excessive re-renders + +## Validation System + +### useValidation Hook +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx` +- Provides core validation logic +- Validates at multiple levels: + 1. Field-level validation (required, regex, unique) + 2. Row-level validation (supplier, company fields) + 3. Table-level validation + 4. Custom validation hooks support +- Error object structure includes message, level, and source properties +- Handles debounced validation updates to avoid UI freezing + +### useAiValidation Hook +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useAiValidation.tsx` +- Manages AI-assisted validation logic and state +- Features: + - Tracks detailed changes per product + - Manages validation progress with estimated completion time + - Handles warnings and change suggestions + - Supports diff generation for changes + - Progress tracking with step indicators + - Prompt management for AI interactions + - Timer management for long-running operations + +### useTemplates Hook +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useTemplates.tsx` +- Comprehensive template management system +- Features: + - Template CRUD operations + - Template application logic + - Default value handling + - Template search and filtering + - Batch template operations + - Template validation + +### useUpcValidation Hook +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useUpcValidation.tsx` +- Dedicated UPC validation management +- Features: + - UPC format validation + - Supplier data validation + - Cache management for validation results + - Batch processing of UPC validations + - Item number generation logic + - Loading state management + +### useFilters Hook +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useFilters.tsx` +- Advanced filtering system for table data +- Features: + - Multiple filter criteria support + - Dynamic filter updates + - Filter persistence + - Filter combination logic + - Performance optimized filtering + +### useValidationState Hook +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx` +- Manages global validation state +- Handles: + - Data updates + - Template management + - Error tracking using Map objects + - Row selection + - Filtering + - UPC validation with caching to prevent duplicate API calls + - Product line loading + - Batch processing of updates + - Default value application for tax_cat and ship_restrictions (defaulting to "0") + - Price field auto-formatting to remove "$" symbols + +### Utility Files +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validationUtils.ts` +- Core validation utility functions +- Includes: + - Field validation logic + - Error message formatting + - Validation rule processing + - Type checking utilities + +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/errorUtils.ts` +- Error handling and formatting utilities +- Includes: + - Error object creation + - Error message formatting + - Error source tracking + - Error level management + +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/dataMutations.ts` +- Data transformation and mutation utilities +- Includes: + - Row data updates + - Batch data processing + - Data structure conversions + - Change tracking + +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validation-helper.js` +- Helper functions for validation +- Includes: + - Common validation patterns + - Validation state management + - Validation result processing + +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/upcValidation.ts` +- UPC-specific validation utilities +- Includes: + - UPC format checking + - Checksum validation + - Supplier data matching + - Cache management + +### Types +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types.ts` +- Core type definitions for the validation step + +### Validation Types +1. Required field validation +2. Regex pattern validation +3. Unique value validation +4. Custom field validation +5. Row-level validation +6. Table-level validation + +## State Management + +### useValidationState Hook +`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx` +- Manages global validation state +- Handles: + - Data updates + - Template management + - Error tracking using Map objects + - Row selection + - Filtering + - UPC validation with caching to prevent duplicate API calls + - Product line loading + - Batch processing of updates + - Default value application for tax_cat and ship_restrictions (defaulting to "0") + - Price field auto-formatting to remove "$" symbols + +## UPC Validation System + +### UPC Processing +- Validates UPCs against supplier data +- Cache system for UPC validation results +- Batch processing of UPC validation requests +- Auto-generation of item numbers based on UPC +- Special loading states for UPC/item number fields +- Separate state tracking to avoid unnecessary data structure updates + +## Template System + +### Template Management +- Supports saving and loading templates +- Template application to single/multiple rows +- Default template values +- Template search and filtering + +## Performance Optimizations +1. Memoized components to prevent unnecessary renders +2. Virtualized table for large datasets +3. Deferred value updates for search inputs +4. Efficient error state management +5. Optimized cell update handling + diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx index cd972cd..0dd7522 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx @@ -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 }) => ( @@ -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 (
@@ -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) || []} />
)} @@ -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 ( @@ -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 - ); -}); \ No newline at end of file + // 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; +} \ No newline at end of file diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx index aaa0989..24aace9 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx @@ -49,6 +49,106 @@ interface ValidationTableProps { [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 ( + + ); + } + + return ( + + ); +}, (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, + value: any, + onChange: (value: any) => void, + errors: ErrorType[], + isValidating?: boolean, + fieldKey: string, + options?: readonly any[], + itemNumber?: string, + width: number, + rowIndex: number, + copyDown?: () => void +}) => { + return ( + + ); +}, (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 = ({ data, fields, @@ -118,25 +218,35 @@ const ValidationTable = ({ return ( - {isLoadingTemplates ? ( - - ) : ( - handleTemplateChange(value, rowIndex)} - getTemplateDisplayText={getTemplateDisplayText} - defaultBrand={defaultBrand} - /> - )} + handleTemplateChange(value, rowIndex)} + getTemplateDisplayText={getTemplateDisplayText} + defaultBrand={defaultBrand} + isLoading={isLoadingTemplates} + /> ); } }), [templates, handleTemplateChange, getTemplateDisplayText, isLoadingTemplates, data]); + // Cache options by field key to avoid recreating arrays + const optionsCache = useMemo(() => { + const cache = new Map(); + + 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 = ({ 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 }) => ( - 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 = ({ ) }; }).filter((col): col is ColumnDef, 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 = ({ ); }; -// Optimize memo comparison +// Optimize memo comparison with more efficient checks const areEqual = (prev: ValidationTableProps, next: ValidationTableProps) => { // 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; }; diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx index 4b14fed..b3eca85 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx @@ -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 { 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 = ({ value, onChange, @@ -26,66 +40,81 @@ const InputCell = ({ isPrice = false, disabled = false }: InputCellProps) => { - 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) => { - 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 = ({ // 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; }); \ No newline at end of file diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiInputCell.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiInputCell.tsx index 279dc57..fa25659 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiInputCell.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiInputCell.tsx @@ -32,6 +32,129 @@ interface MultiInputCellProps { // 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 +}) => ( + onSelect(option.value)} + className="flex w-full" + > +
+ + {option.label} +
+
+), (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, + onSelect: (value: string) => void, + maxHeight?: number +}) => { + const listRef = useRef(null); + + // Only render visible options for better performance with large lists + const [visibleOptions, setVisibleOptions] = useState([]); + 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 ( +
+ {options.map(option => ( + + ))} +
+ ); + } + + return ( +
+
+
+ {visibleOptions.map(option => ( + + ))} +
+
+
+ ); +}); + +VirtualizedOptions.displayName = 'VirtualizedOptions'; + const MultiInputCell = ({ field, value = [], @@ -52,6 +175,9 @@ const MultiInputCell = ({ // Ref for the command list to enable scrolling const commandListRef = useRef(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 = ({ } 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 = ({ []; // 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 = ({ return [...prev, selectedValue]; } }); - setSearchQuery(""); }, []); // Handle focus @@ -211,28 +378,6 @@ const MultiInputCell = ({ // Create a reference to the container element const containerRef = useRef(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 = ({ 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 (
({ onValueChange={setSearchQuery} /> No options found. - {sortedOptions.map((option) => ( - 0 ? ( + -
- - {option.label} -
-
- ))} + maxHeight={200} + /> + ) : ( +
No options match your search
+ )}
@@ -346,28 +509,6 @@ const MultiInputCell = ({ // Create a reference to the container element const containerRef = useRef(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 = ({ 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 (
({ MultiInputCell.displayName = 'MultiInputCell'; -export default React.memo(MultiInputCell); \ No newline at end of file +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; +}); \ No newline at end of file diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx index 13e3496..a1fd826 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx @@ -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 = ({ 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 = ({ // 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 = ({ }, 0); }, [onChange, onEndEdit]); - // Memoize the command items to avoid recreating them on every render - const commandItems = useMemo(() => { - return selectOptions.map((option: SelectOption) => ( - handleSelect(option.value)} - className="cursor-pointer" - > - {option.label} - {String(option.value) === String(internalValue) && ( - - )} - - )); - }, [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 (
({ align="start" sideOffset={4} > - + ({ > No results found. - {commandItems} + {selectOptions.map((option: SelectOption) => ( + handleSelect(option.value)} + className="cursor-pointer" + > + {option.label} + {String(option.value) === String(internalValue) && ( + + )} + + ))} @@ -208,10 +221,15 @@ const SelectCell = ({ // Optimize memo comparison to avoid unnecessary re-renders export default React.memo(SelectCell, (prev, next) => { // Only rerender when these critical props change - return ( - prev.value === next.value && - prev.hasErrors === next.hasErrors && - prev.disabled === next.disabled && - prev.options === next.options - ); + 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; }); \ No newline at end of file diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx index 50db338..73fedea 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx @@ -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 = ( fields: Fields, rowHook?: RowHook, @@ -32,15 +40,8 @@ export const useValidation = ( 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 = ( // Run field-level validations const fieldErrors: Record = {} - // 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 = ( 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 = ( // Run complete validation const validateData = useCallback(async (data: RowData[]) => { - // 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( diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx index aeaffeb..5f9159d 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx @@ -225,8 +225,49 @@ export const useValidationState = ({ 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 = ({ 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 = ({ // Update validateUniqueItemNumbers to use batch updates const validateUniqueItemNumbers = useCallback(async () => { - const duplicates = new Map(); - const itemNumberMap = new Map(); - + 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(); + + // Initialize batch updates + const errors = new Map>(); + + // 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 => { @@ -664,8 +773,14 @@ export const useValidationState = ({ const fieldErrors: Record = {}; 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 = ({ return; } - // Validate the field - const errors = validateField(value, field as Field); - 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); + 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 = ({ // 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 = ({ 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 = ({ } }, [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 = ({ } 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 = ({ 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 = ({ 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>(); + const batchStatuses = new Map(); + + // 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; // 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; - // 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 = ({ 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 = ({ 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>(); + const batchStatuses = new Map(); 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(resolve => { @@ -1274,36 +1441,52 @@ export const useValidationState = ({ } as RowData; } - // 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; - 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; + + 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; } - // 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); - 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 = ({ }]; hasErrors = true; } - if (!row.company) { + + if (hasCompanyField && !row.company) { fieldErrors['company'] = [{ message: 'Company is required', level: 'error', @@ -1320,11 +1504,13 @@ export const useValidationState = ({ 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 = ({ // 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;