From 0068d77ad946f4e23a84f0c0eb911f9d7e302eef Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 10 Mar 2025 21:59:24 -0400 Subject: [PATCH] Optimize validation table --- .../components/ValidationCell.tsx | 3 +- .../components/ValidationContainer.tsx | 267 +++++---- .../components/ValidationTable.tsx | 92 +++- .../components/cells/InputCell.tsx | 119 ++-- .../components/cells/MultiInputCell.tsx | 25 +- .../components/cells/SelectCell.tsx | 143 +++-- .../hooks/useValidationState.tsx | 512 ++++++------------ 7 files changed, 592 insertions(+), 569 deletions(-) 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 5ef9852..cd972cd 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 @@ -376,7 +376,8 @@ export default React.memo(ValidationCell, (prev, next) => { return ( prev.value === next.value && prevErrorsStr === nextErrorsStr && - prevOptionsStr === nextOptionsStr + // Only do the deep comparison if the references are different + (prev.options === next.options || prevOptionsStr === nextOptionsStr) ); } diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx index 2a0babe..d2226a6 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx @@ -430,7 +430,10 @@ const ValidationContainer = ({ // Fetch product lines for the new company if rowData has __index if (rowData && rowData.__index) { - await fetchProductLines(rowData.__index, value.toString()); + // Use setTimeout to make this non-blocking + setTimeout(async () => { + await fetchProductLines(rowData.__index, value.toString()); + }, 0); } } @@ -440,28 +443,36 @@ const ValidationContainer = ({ if (rowDataAny.upc || rowDataAny.barcode) { const upcValue = rowDataAny.upc || rowDataAny.barcode; - // Mark this row as being validated - setValidatingUpcRows(prev => { - const newSet = new Set(prev); - newSet.add(rowIndex); - return newSet; - }); - - // Set global validation state - setIsValidatingUpc(true); - - // Use supplier ID (the value being set) to validate UPC - await validateUpc(rowIndex, value.toString(), upcValue.toString()); - - // Update validation state - setValidatingUpcRows(prev => { - const newSet = new Set(prev); - newSet.delete(rowIndex); - if (newSet.size === 0) { - setIsValidatingUpc(false); + // Run UPC validation in a non-blocking way - with a slight delay + // This allows the UI to update with the selected value first + setTimeout(async () => { + try { + // Mark this row as being validated + setValidatingUpcRows(prev => { + const newSet = new Set(prev); + newSet.add(rowIndex); + return newSet; + }); + + // Set global validation state + setIsValidatingUpc(true); + + // Use supplier ID (the value being set) to validate UPC + await validateUpc(rowIndex, value.toString(), upcValue.toString()); + } catch (error) { + console.error('Error validating UPC:', error); + } finally { + // Always clean up validation state, even if there was an error + setValidatingUpcRows(prev => { + const newSet = new Set(prev); + newSet.delete(rowIndex); + if (newSet.size === 0) { + setIsValidatingUpc(false); + } + return newSet; + }); } - return newSet; - }); + }, 200); // Slight delay to let the UI update first } } @@ -481,7 +492,10 @@ const ValidationContainer = ({ // Fetch sublines for the new line if rowData has __index if (rowData && rowData.__index) { - await fetchSublines(rowData.__index, value.toString()); + // Use setTimeout to make this non-blocking + setTimeout(async () => { + await fetchSublines(rowData.__index, value.toString()); + }, 0); } } @@ -489,28 +503,35 @@ const ValidationContainer = ({ if ((fieldKey === 'upc' || fieldKey === 'barcode') && value && rowData) { const rowDataAny = rowData as Record; if (rowDataAny.supplier) { - // Mark this row as being validated - setValidatingUpcRows(prev => { - const newSet = new Set(prev); - newSet.add(rowIndex); - return newSet; - }); - - // Set global validation state - setIsValidatingUpc(true); - - // Use supplier ID from the row data (NOT company ID) to validate UPC - await validateUpc(rowIndex, rowDataAny.supplier.toString(), value.toString()); - - // Update validation state - setValidatingUpcRows(prev => { - const newSet = new Set(prev); - newSet.delete(rowIndex); - if (newSet.size === 0) { - setIsValidatingUpc(false); + // Run UPC validation in a non-blocking way + setTimeout(async () => { + try { + // Mark this row as being validated + setValidatingUpcRows(prev => { + const newSet = new Set(prev); + newSet.add(rowIndex); + return newSet; + }); + + // Set global validation state + setIsValidatingUpc(true); + + // Use supplier ID from the row data (NOT company ID) to validate UPC + await validateUpc(rowIndex, rowDataAny.supplier.toString(), value.toString()); + } catch (error) { + console.error('Error validating UPC:', error); + } finally { + // Always clean up validation state, even if there was an error + setValidatingUpcRows(prev => { + const newSet = new Set(prev); + newSet.delete(rowIndex); + if (newSet.size === 0) { + setIsValidatingUpc(false); + } + return newSet; + }); } - return newSet; - }); + }, 200); // Slight delay to let the UI update first } } }, [data, filteredData, updateRow, fetchProductLines, fetchSublines, validateUpc, setData]); @@ -792,39 +813,59 @@ const ValidationContainer = ({ }); }, [data, rowSelection, setData, setRowSelection]); - // Enhanced ValidationTable component that's aware of item numbers - const EnhancedValidationTable = useCallback((props: React.ComponentProps) => { - // Create validatingCells set from validatingUpcRows - const validatingCells = useMemo(() => { - const cells = new Set(); - validatingUpcRows.forEach(rowIndex => { - cells.add(`${rowIndex}-upc`); - cells.add(`${rowIndex}-item_number`); - }); - return cells; - }, [validatingUpcRows]); + // Memoize handlers + const handleFiltersChange = useCallback((newFilters: any) => { + updateFilters(newFilters); + }, [updateFilters]); + + const handleRowSelectionChange = useCallback((newSelection: RowSelectionState) => { + setRowSelection(newSelection); + }, [setRowSelection]); + + const handleUpdateRow = useCallback((rowIndex: number, key: T, value: any) => { + enhancedUpdateRow(rowIndex, key, value); + }, [enhancedUpdateRow]); + + // Enhanced copy down that uses enhancedUpdateRow instead of regular updateRow + const handleCopyDown = useCallback((rowIndex: number, fieldKey: string) => { + // Get the value to copy from the source row + const sourceRow = data[rowIndex]; + const valueToCopy = sourceRow[fieldKey]; + + // Get all rows below the source row + const rowsBelow = data.slice(rowIndex + 1); + + // Update each row below with the copied value + rowsBelow.forEach((_, index) => { + const targetRowIndex = rowIndex + 1 + index; + enhancedUpdateRow(targetRowIndex, fieldKey as T, valueToCopy); + }); + }, [data, enhancedUpdateRow]); + + // Memoize the enhanced validation table component + const EnhancedValidationTable = useMemo(() => React.memo((props: React.ComponentProps) => { + // Create validatingCells set from validatingUpcRows, but only for UPC and item_number fields + // This ensures supplier fields don't disappear during UPC validation + const validatingCells = new Set(); + validatingUpcRows.forEach(rowIndex => { + // Only mark the UPC and item_number cells as validating, NOT the supplier + validatingCells.add(`${rowIndex}-upc`); + validatingCells.add(`${rowIndex}-item_number`); + }); // Convert itemNumbers to Map - const itemNumbersMap = useMemo(() => - new Map(Object.entries(itemNumbers).map(([key, value]) => [parseInt(key), value])), - [itemNumbers] - ); + const itemNumbersMap = new Map(Object.entries(itemNumbers).map(([key, value]) => [parseInt(key), value])); // Merge the item numbers with the data for display purposes only - const enhancedData = useMemo(() => { - if (Object.keys(itemNumbers).length === 0) return props.data; - - // Create a new array with the item numbers merged in - return props.data.map((row: any, index: number) => { - if (itemNumbers[index]) { - return { - ...row, - item_number: itemNumbers[index] - }; - } - return row; - }); - }, [props.data, itemNumbers]); + const enhancedData = props.data.map((row: any, index: number) => { + if (itemNumbers[index]) { + return { + ...row, + item_number: itemNumbers[index] + }; + } + return row; + }); return ( ({ validatingCells={validatingCells} itemNumbers={itemNumbersMap} isLoadingTemplates={isLoadingTemplates} - copyDown={copyDown} + copyDown={handleCopyDown} /> ); - }, [validatingUpcRows, itemNumbers, isLoadingTemplates, copyDown]); + }), [validatingUpcRows, itemNumbers, isLoadingTemplates, handleCopyDown]); - // Memoize the ValidationTable to prevent unnecessary re-renders - const renderValidationTable = useMemo(() => { - return ( - - ); - }, [ + // Memoize the rendered validation table + const renderValidationTable = useMemo(() => ( + + ), [ EnhancedValidationTable, - filteredData, - fields, - rowSelection, - setRowSelection, - updateRow, + filteredData, + fields, + rowSelection, + handleRowSelectionChange, + handleUpdateRow, validationErrors, - isRowValidatingUpc, + isRowValidatingUpc, validatingUpcRows, - filters, - templates, - applyTemplate, + filters, + templates, + applyTemplate, getTemplateDisplayText, isLoadingTemplates, - copyDown + handleCopyDown ]); // Add scroll container ref at the container level @@ -883,13 +922,14 @@ const ValidationContainer = ({ const lastScrollPosition = useRef({ left: 0, top: 0 }); const isScrolling = useRef(false); - // Save scroll position when scrolling - const handleScroll = useCallback(() => { - if (!isScrolling.current && scrollContainerRef.current) { + // Memoize scroll handlers + const handleScroll = useCallback((event: React.UIEvent) => { + if (!isScrolling.current) { isScrolling.current = true; + const target = event.currentTarget; lastScrollPosition.current = { - left: scrollContainerRef.current.scrollLeft, - top: scrollContainerRef.current.scrollTop + left: target.scrollLeft, + top: target.scrollTop }; requestAnimationFrame(() => { isScrolling.current = false; @@ -993,6 +1033,7 @@ const ValidationContainer = ({ position: 'relative', WebkitOverflowScrolling: 'touch' // Improve scroll performance on Safari }} + onScroll={handleScroll} >
{/* Force container to be at least as wide as content */} {renderValidationTable} 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 9e0a441..aaa0989 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 @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import React, { useMemo, useCallback } from 'react' import { useReactTable, getCoreRowModel, @@ -67,14 +67,22 @@ const ValidationTable = ({ }: ValidationTableProps) => { const { translations } = useRsi(); - // Memoize the selection column + // Memoize the selection column with stable callback + const handleSelectAll = useCallback((value: boolean, table: any) => { + table.toggleAllPageRowsSelected(!!value); + }, []); + + const handleRowSelect = useCallback((value: boolean, row: any) => { + row.toggleSelected(!!value); + }, []); + const selectionColumn = useMemo((): ColumnDef, any> => ({ id: 'select', header: ({ table }) => (
table.toggleAllPageRowsSelected(!!value)} + onCheckedChange={(value) => handleSelectAll(!!value, table)} aria-label="Select all" />
@@ -83,7 +91,7 @@ const ValidationTable = ({
row.toggleSelected(!!value)} + onCheckedChange={(value) => handleRowSelect(!!value, row)} aria-label="Select row" />
@@ -91,9 +99,14 @@ const ValidationTable = ({ enableSorting: false, enableHiding: false, size: 50, - }), []); + }), [handleSelectAll, handleRowSelect]); - // Memoize the template column + // Memoize template selection handler + const handleTemplateChange = useCallback((value: string, rowIndex: number) => { + applyTemplate(value, [rowIndex]); + }, [applyTemplate]); + + // Memoize the template column with stable callback const templateColumn = useMemo((): ColumnDef, any> => ({ accessorKey: '__template', header: 'Template', @@ -114,9 +127,7 @@ const ValidationTable = ({ { - applyTemplate(value, [rowIndex]); - }} + onValueChange={(value) => handleTemplateChange(value, rowIndex)} getTemplateDisplayText={getTemplateDisplayText} defaultBrand={defaultBrand} /> @@ -124,9 +135,19 @@ const ValidationTable = ({ ); } - }), [templates, applyTemplate, getTemplateDisplayText, isLoadingTemplates, data]); + }), [templates, handleTemplateChange, getTemplateDisplayText, isLoadingTemplates, data]); - // Memoize field columns + // Memoize the field update handler + const handleFieldUpdate = useCallback((rowIndex: number, fieldKey: T, value: any) => { + updateRow(rowIndex, fieldKey, value); + }, [updateRow]); + + // Memoize the copyDown handler + const handleCopyDown = useCallback((rowIndex: number, fieldKey: string) => { + copyDown(rowIndex, fieldKey); + }, [copyDown]); + + // Memoize field columns with stable handlers const fieldColumns = useMemo(() => fields.map((field): ColumnDef, any> | null => { if (field.disabled) return null; @@ -147,7 +168,7 @@ const ValidationTable = ({ updateRow(row.index, field.key, value)} + onChange={(value) => handleFieldUpdate(row.index, field.key, value)} errors={validationErrors.get(row.index)?.[String(field.key)] || []} isValidating={validatingCells.has(`${row.index}-${field.key}`)} fieldKey={String(field.key)} @@ -155,11 +176,12 @@ const ValidationTable = ({ itemNumber={itemNumbers.get(row.index)} width={fieldWidth} rowIndex={row.index} - copyDown={() => copyDown(row.index, field.key)} + copyDown={() => handleCopyDown(row.index, field.key)} /> ) }; - }).filter((col): col is ColumnDef, any> => col !== null), [fields, validationErrors, validatingCells, itemNumbers, updateRow, copyDown]); + }).filter((col): col is ColumnDef, any> => col !== null), + [fields, validationErrors, validatingCells, itemNumbers, handleFieldUpdate, handleCopyDown]); // Combine columns const columns = useMemo(() => [selectionColumn, templateColumn, ...fieldColumns], [selectionColumn, templateColumn, fieldColumns]); @@ -247,13 +269,43 @@ const ValidationTable = ({ ); }; -export default React.memo(ValidationTable, (prev, next) => { - // Add more specific checks to prevent unnecessary re-renders - if (prev.data.length !== next.data.length) return false; - if (prev.validationErrors.size !== next.validationErrors.size) return false; +// Optimize memo comparison +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 + if (prev.data.length !== next.data.length) return false; + + // Check row selection changes + const prevSelectionKeys = Object.keys(prev.rowSelection); + const nextSelectionKeys = Object.keys(next.rowSelection); + if (prevSelectionKeys.length !== nextSelectionKeys.length) return false; + if (!prevSelectionKeys.every(key => prev.rowSelection[key] === next.rowSelection[key])) return false; + + // Check validation errors + if (prev.validationErrors.size !== next.validationErrors.size) return false; + for (const [key, value] of prev.validationErrors) { + const nextValue = next.validationErrors.get(key); + if (!nextValue || Object.keys(value).length !== Object.keys(nextValue).length) return false; + } + + // Check validating cells if (prev.validatingCells.size !== next.validatingCells.size) return false; + for (const cell of prev.validatingCells) { + if (!next.validatingCells.has(cell)) return false; + } + + // Check item numbers if (prev.itemNumbers.size !== next.itemNumbers.size) return false; - if (prev.templates.length !== next.templates.length) return false; + for (const [key, value] of prev.itemNumbers) { + if (next.itemNumbers.get(key) !== value) return false; + } + return true; -}); \ No newline at end of file +}; + +export default React.memo(ValidationTable, areEqual); \ No newline at end of file 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 0306537..4b14fed 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 } from 'react' +import React, { useState, useCallback, useDeferredValue, useTransition } from 'react' import { Field } from '../../../../types' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' @@ -13,6 +13,7 @@ interface InputCellProps { hasErrors?: boolean isMultiline?: boolean isPrice?: boolean + disabled?: boolean } const InputCell = ({ @@ -22,12 +23,15 @@ const InputCell = ({ onEndEdit, hasErrors, isMultiline = false, - isPrice = false + isPrice = false, + disabled = false }: InputCellProps) => { const [isEditing, setIsEditing] = useState(false) const [editValue, setEditValue] = useState('') + const [isPending, startTransition] = useTransition() + const deferredEditValue = useDeferredValue(editValue) - // Handle focus event + // Handle focus event - optimized to be synchronous const handleFocus = useCallback(() => { setIsEditing(true) @@ -43,63 +47,59 @@ const InputCell = ({ onStartEdit?.() }, [value, onStartEdit, isPrice]) - // Handle blur event + // Handle blur event - use transition for non-critical updates const handleBlur = useCallback(() => { - setIsEditing(false) - - // Format the value for storage (remove formatting like $ for price) - let processedValue = editValue - - if (isPrice) { - // Remove any non-numeric characters except decimal point - processedValue = editValue.replace(/[^\d.]/g, '') + startTransition(() => { + setIsEditing(false) - // Parse as float and format to 2 decimal places to ensure valid number - const numValue = parseFloat(processedValue) - if (!isNaN(numValue)) { - processedValue = numValue.toFixed(2) + // Format the value for storage (remove formatting like $ for price) + let processedValue = deferredEditValue + + 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) + } } - } - - onChange(processedValue) - onEndEdit?.() - }, [editValue, onChange, onEndEdit, isPrice]) + + onChange(processedValue) + onEndEdit?.() + }) + }, [deferredEditValue, onChange, onEndEdit, isPrice]) - // Handle direct input change + // Handle direct input change - optimized to be synchronous for typing const handleChange = useCallback((e: React.ChangeEvent) => { - let newValue = e.target.value - - // For price fields, automatically strip dollar signs as they type - if (isPrice) { - newValue = newValue.replace(/[$,]/g, '') - - // If they try to enter a dollar sign, just remove it immediately - if (e.target.value.includes('$')) { - e.target.value = newValue - } - } - + const newValue = isPrice ? e.target.value.replace(/[$,]/g, '') : e.target.value setEditValue(newValue) }, [isPrice]) - // Format price value for display - const getDisplayValue = useCallback(() => { - if (!isPrice || !value) return value - - // Extract numeric part - const numericValue = String(value).replace(/[^\d.]/g, '') - - // Parse as float and format without dollar sign - const numValue = parseFloat(numericValue) - if (isNaN(numValue)) return value - - // Return just the number without dollar sign - return numValue.toFixed(2) - }, [value, isPrice]) + // Format price value for display - memoized and deferred + const displayValue = useDeferredValue( + isPrice && value ? + parseFloat(String(value).replace(/[^\d.]/g, '')).toFixed(2) : + value ?? '' + ) // Add outline even when not in focus const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0" + // If disabled, just render the value without any interactivity + if (disabled) { + return ( +
+ {displayValue} +
+ ); + } + return (
{isMultiline ? ( @@ -125,7 +125,8 @@ const InputCell = ({ autoFocus className={cn( outlineClass, - hasErrors ? "border-destructive" : "" + hasErrors ? "border-destructive" : "", + isPending ? "opacity-50" : "" )} /> ) : ( @@ -137,7 +138,7 @@ const InputCell = ({ hasErrors ? "border-destructive" : "border-input" )} > - {isPrice ? getDisplayValue() : (value ?? '')} + {displayValue}
) )} @@ -145,13 +146,13 @@ const InputCell = ({ ) } -// Memoize the component with a strict comparison function +// Optimize memo comparison to focus on essential props export default React.memo(InputCell, (prev, next) => { - // Only re-render if these props change - return ( - prev.value === next.value && - prev.hasErrors === next.hasErrors && - prev.isMultiline === next.isMultiline && - prev.isPrice === next.isPrice - ) -}) \ No newline at end of file + 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; + 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 89d9550..279dc57 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 @@ -26,6 +26,7 @@ interface MultiInputCellProps { isMultiline?: boolean isPrice?: boolean options?: readonly FieldOption[] + disabled?: boolean } // Add global CSS to ensure fixed width constraints - use !important to override other styles @@ -41,7 +42,8 @@ const MultiInputCell = ({ separator = ',', isMultiline = false, isPrice = false, - options: providedOptions + options: providedOptions, + disabled = false }: MultiInputCellProps) => { const [open, setOpen] = useState(false) const [searchQuery, setSearchQuery] = useState("") @@ -180,6 +182,27 @@ const MultiInputCell = ({ // Add outline even when not in focus const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0" + // If disabled, render a static view + if (disabled) { + // Handle array values + const displayValue = Array.isArray(value) + ? value.map(v => { + const option = providedOptions?.find(o => o.value === v); + return option ? option.label : v; + }).join(', ') + : value; + + return ( +
+ {displayValue || ""} +
+ ); + } + // If we have a multi-select field with options, use command UI if (field.fieldType.type === 'multi-select' && selectOptions.length > 0) { // Get width from field if available, or default to a reasonable value 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 c6c1f10..13e3496 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 @@ -2,20 +2,10 @@ import { useState, useRef, useCallback, useMemo } from 'react' import { Field } from '../../../../types' import { Check, ChevronsUpDown } from 'lucide-react' import { Button } from '@/components/ui/button' -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from '@/components/ui/command' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/ui/popover' +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' +import React from 'react' export type SelectOption = { label: string; @@ -24,14 +14,16 @@ export type SelectOption = { interface SelectCellProps { field: Field - value: string - onChange: (value: string) => void + value: any + onChange: (value: any) => void onStartEdit?: () => void onEndEdit?: () => void hasErrors?: boolean - options?: readonly SelectOption[] + options: readonly any[] + disabled?: boolean } +// Lightweight version of the select cell with minimal dependencies const SelectCell = ({ field, value, @@ -39,11 +31,27 @@ const SelectCell = ({ onStartEdit, onEndEdit, hasErrors, - options + options = [], + disabled = false }: SelectCellProps) => { - const [open, setOpen] = useState(false) - // Ref for the command list to enable scrolling - const commandListRef = useRef(null) + // State for the open/closed state of the dropdown + const [open, setOpen] = useState(false); + + // Ref for the command list + const commandListRef = useRef(null); + + // Controlled state for the internal value - this is key to prevent reopening + const [internalValue, setInternalValue] = useState(value); + + // State to track if the value is being processed/validated + const [isProcessing, setIsProcessing] = useState(false); + + // Update internal value when prop value changes + React.useEffect(() => { + setInternalValue(value); + // When the value prop changes, it means validation is complete + setIsProcessing(false); + }, [value]); // Memoize options processing to avoid recalculation on every render const selectOptions = useMemo(() => { @@ -62,7 +70,6 @@ const SelectCell = ({ })); if (processedOptions.length === 0) { - // Add a default empty option if we have none processedOptions.push({ label: 'No options available', value: '' }); } @@ -71,12 +78,12 @@ const SelectCell = ({ // Memoize display value to avoid recalculation on every render const displayValue = useMemo(() => { - return value ? - selectOptions.find((option: SelectOption) => String(option.value) === String(value))?.label || String(value) : + return internalValue ? + selectOptions.find((option: SelectOption) => String(option.value) === String(internalValue))?.label || String(internalValue) : 'Select...'; - }, [value, selectOptions]); + }, [internalValue, selectOptions]); - // Handle wheel scroll in dropdown + // Handle wheel scroll in dropdown - optimized with passive event const handleWheel = useCallback((e: React.WheelEvent) => { if (commandListRef.current) { e.stopPropagation(); @@ -84,10 +91,25 @@ const SelectCell = ({ } }, []); + // Handle selection - UPDATE INTERNAL VALUE FIRST const handleSelect = useCallback((selectedValue: string) => { - onChange(selectedValue); + // 1. Update internal value immediately to prevent UI flicker + setInternalValue(selectedValue); + + // 2. Close the dropdown immediately setOpen(false); + + // 3. Set processing state to show visual indicator + setIsProcessing(true); + + // 4. Only then call the onChange callback + // This prevents the parent component from re-rendering and causing dropdown to reopen if (onEndEdit) onEndEdit(); + + // 5. Call onChange in the next tick to avoid synchronous re-renders + setTimeout(() => { + onChange(selectedValue); + }, 0); }, [onChange, onEndEdit]); // Memoize the command items to avoid recreating them on every render @@ -97,17 +119,41 @@ const SelectCell = ({ key={option.value} value={option.label} onSelect={() => handleSelect(option.value)} + className="cursor-pointer" > {option.label} - {String(option.value) === String(value) && ( + {String(option.value) === String(internalValue) && ( )} )); - }, [selectOptions, value, handleSelect]); + }, [selectOptions, internalValue, handleSelect]); + + // If disabled, render a static view + if (disabled) { + const selectedOption = options.find(o => o.value === internalValue); + const displayText = selectedOption ? selectedOption.label : internalValue; + + return ( +
+ {displayText || ""} +
+ ); + } return ( - + { + setOpen(isOpen); + if (isOpen && onStartEdit) onStartEdit(); + }} + > - - - + + + ({ ) } -export default SelectCell \ No newline at end of file +// 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 + ); +}); \ No newline at end of file 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 2a70e6c..aeaffeb 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 @@ -78,6 +78,21 @@ declare global { // Use a helper to get API URL consistently export const getApiUrl = () => config.apiUrl; +// Add debounce utility +const DEBOUNCE_DELAY = 300; +const BATCH_SIZE = 5; + +function debounce any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: NodeJS.Timeout; + return (...args: Parameters) => { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; +} + // Main validation state hook export const useValidationState = ({ initialData, @@ -165,7 +180,7 @@ export const useValidationState = ({ const [isValidating] = useState(false) const [validationErrors, setValidationErrors] = useState>>(new Map()) const [rowValidationStatus, setRowValidationStatus] = useState>(new Map()) - const [validatingCells, setValidatingCells] = useState>(new Set()) + const [, setValidatingCells] = useState>(new Set()) // Template state const [templates, setTemplates] = useState([]) @@ -195,163 +210,119 @@ export const useValidationState = ({ // Add debounce timer ref for item number validation - // Function to validate uniqueness of item numbers across the entire table - const validateItemNumberUniqueness = useCallback(() => { - // Create a map to track item numbers and their occurrences - const itemNumberMap = new Map(); + // Add batch update state + const pendingUpdatesRef = useRef<{ + errors: Map>, + statuses: Map, + data: Array> + }>({ + errors: new Map(), + statuses: new Map(), + data: [] + }); + + // Optimized batch update function + const flushPendingUpdates = useCallback(() => { + const updates = pendingUpdatesRef.current; - // First pass: collect all item numbers and their row indices - data.forEach((row, rowIndex) => { - const itemNumber = row.item_number; + if (updates.errors.size > 0) { + setValidationErrors(prev => { + const newErrors = new Map(prev); + updates.errors.forEach((errors, rowIndex) => { + if (Object.keys(errors).length === 0) { + newErrors.delete(rowIndex); + } else { + newErrors.set(rowIndex, errors); + } + }); + return newErrors; + }); + updates.errors = new Map(); + } + + if (updates.statuses.size > 0) { + setRowValidationStatus(prev => { + const newStatuses = new Map(prev); + updates.statuses.forEach((status, rowIndex) => { + newStatuses.set(rowIndex, status); + }); + return newStatuses; + }); + updates.statuses = new Map(); + } + + if (updates.data.length > 0) { + setData(prev => { + const newData = [...prev]; + updates.data.forEach((row, index) => { + newData[index] = row; + }); + return newData; + }); + updates.data = []; + } + }, []); + + // Debounced flush updates + const debouncedFlushUpdates = useMemo( + () => debounce(flushPendingUpdates, DEBOUNCE_DELAY), + [flushPendingUpdates] + ); + + // Queue updates instead of immediate setState calls + const queueUpdate = useCallback((rowIndex: number, updates: { + errors?: Record, + status?: 'pending' | 'validating' | 'validated' | 'error', + data?: RowData + }) => { + if (updates.errors) { + pendingUpdatesRef.current.errors.set(rowIndex, updates.errors); + } + if (updates.status) { + pendingUpdatesRef.current.statuses.set(rowIndex, updates.status); + } + if (updates.data) { + pendingUpdatesRef.current.data[rowIndex] = updates.data; + } + debouncedFlushUpdates(); + }, [debouncedFlushUpdates]); + + // Update validateUniqueItemNumbers to use batch updates + const validateUniqueItemNumbers = useCallback(async () => { + const duplicates = new Map(); + const itemNumberMap = new Map(); + + data.forEach((row, index) => { + const itemNumber = row.item_number?.toString(); if (itemNumber) { - if (!itemNumberMap.has(itemNumber)) { - itemNumberMap.set(itemNumber, [rowIndex]); + if (itemNumberMap.has(itemNumber)) { + const existingIndex = itemNumberMap.get(itemNumber)!; + if (!duplicates.has(itemNumber)) { + duplicates.set(itemNumber, [existingIndex]); + } + duplicates.get(itemNumber)!.push(index); } else { - itemNumberMap.get(itemNumber)?.push(rowIndex); + itemNumberMap.set(itemNumber, index); } } }); - - // Only process duplicates - skip if no duplicates found - const duplicates = Array.from(itemNumberMap.entries()) - .filter(([_, indices]) => indices.length > 1); - - if (duplicates.length === 0) return; - - // Prepare batch updates to minimize re-renders - const errorsToUpdate = new Map>(); - const statusesToUpdate = new Map(); - const rowsToUpdate: {rowIndex: number, errors: Record}[] = []; - - // Process only duplicates - duplicates.forEach(([, rowIndices]) => { + + duplicates.forEach((rowIndices, itemNumber) => { rowIndices.forEach(rowIndex => { - // Collect errors for batch update - const rowErrors = validationErrors.get(rowIndex) || {}; - errorsToUpdate.set(rowIndex, { - ...rowErrors, + const errors = { item_number: [{ - message: 'Duplicate item number', + message: `Duplicate item number: ${itemNumber}`, level: 'error', source: 'validation' }] - }); - - // Collect status updates - statusesToUpdate.set(rowIndex, 'error'); - - // Collect data updates - rowsToUpdate.push({ - rowIndex, - errors: { - ...(data[rowIndex].__errors || {}), - item_number: [{ - message: 'Duplicate item number', - level: 'error', - source: 'validation' - }] - } - }); - }); - }); - - // Apply all updates in batch - if (errorsToUpdate.size > 0) { - // Update validation errors - setValidationErrors(prev => { - const updated = new Map(prev); - errorsToUpdate.forEach((errors, rowIndex) => { - updated.set(rowIndex, errors); - }); - return updated; - }); - - // Update row statuses - setRowValidationStatus(prev => { - const updated = new Map(prev); - statusesToUpdate.forEach((status, rowIndex) => { - updated.set(rowIndex, status); - }); - return updated; - }); - - // Update data rows - if (rowsToUpdate.length > 0) { - setData(prevData => { - const newData = [...prevData]; - rowsToUpdate.forEach(({rowIndex, errors}) => { - if (newData[rowIndex]) { - newData[rowIndex] = { - ...newData[rowIndex], - __errors: errors - }; - } - }); - return newData; - }); - } - } - }, [data, validationErrors]); - - // Effect to update data when UPC validation results change -useEffect(() => { - if (upcValidationResults.size === 0) return; - - // Save scroll position - const scrollPosition = { - left: window.scrollX, - top: window.scrollY - }; - - // Process all updates in a single batch - const updatedData = [...data]; - const updatedErrors = new Map(validationErrors); - const updatedStatus = new Map(rowValidationStatus); - let hasChanges = false; - - upcValidationResults.forEach((result, rowIndex) => { - if (result.itemNumber && updatedData[rowIndex]) { - // Only update if the item number has actually changed - if (updatedData[rowIndex].item_number !== result.itemNumber) { - hasChanges = true; - - // Update item number - updatedData[rowIndex] = { - ...updatedData[rowIndex], - item_number: result.itemNumber }; - - // Clear item_number errors - const rowErrors = {...(updatedErrors.get(rowIndex) || {})}; - delete rowErrors.item_number; - - if (Object.keys(rowErrors).length > 0) { - updatedErrors.set(rowIndex, rowErrors); - } else { - updatedStatus.set(rowIndex, 'validated'); - } - } - } - }); - - // Only update state if there were changes - if (hasChanges) { - // Apply all updates - setData(updatedData); - setValidationErrors(updatedErrors); - setRowValidationStatus(updatedStatus); - - // Validate uniqueness after state updates - requestAnimationFrame(() => { - // Restore scroll position - window.scrollTo(scrollPosition.left, scrollPosition.top); - - // Check for duplicate item numbers - validateItemNumberUniqueness(); + queueUpdate(rowIndex, { errors }); + }); }); - } -}, [upcValidationResults, data, validationErrors, rowValidationStatus, validateItemNumberUniqueness]); - + + debouncedFlushUpdates(); + }, [data, queueUpdate, debouncedFlushUpdates]); + // Fetch product by UPC from API - optimized with proper error handling and types const fetchProductByUpc = useCallback(async (supplier: string, upc: string): Promise => { try { @@ -441,146 +412,86 @@ useEffect(() => { } }, []); + // Add batch validation queue + const validationQueueRef = useRef<{rowIndex: number, supplierId: string, upcValue: string}[]>([]); + const isProcessingBatchRef = useRef(false); + + // Process validation queue in batches + const processBatchValidation = useCallback(async () => { + if (isProcessingBatchRef.current) return; + if (validationQueueRef.current.length === 0) return; + + isProcessingBatchRef.current = true; + const batch = validationQueueRef.current.splice(0, BATCH_SIZE); + + try { + await Promise.all(batch.map(async ({ rowIndex, supplierId, upcValue }) => { + // Skip if already validated + const cacheKey = `${supplierId}-${upcValue}`; + if (processedUpcMapRef.current.has(cacheKey)) return; + + const result = await fetchProductByUpc(supplierId, upcValue); + + if (!result.error && result.data?.itemNumber) { + processedUpcMapRef.current.set(cacheKey, result.data.itemNumber); + setUpcValidationResults(prev => { + const newResults = new Map(prev); + newResults.set(rowIndex, { itemNumber: result.data?.itemNumber || '' }); + return newResults; + }); + } + })); + } finally { + isProcessingBatchRef.current = false; + + // Process next batch if queue not empty + if (validationQueueRef.current.length > 0) { + processBatchValidation(); + } + } + }, [fetchProductByUpc]); + + // Debounced version of processBatchValidation + const debouncedProcessBatch = useMemo( + () => debounce(processBatchValidation, DEBOUNCE_DELAY), + [processBatchValidation] + ); + + // Modified validateUpc to use queue const validateUpc = useCallback(async (rowIndex: number, supplierId: string, upcValue: string): Promise<{ success: boolean, itemNumber?: string }> => { try { - // Skip if either value is missing if (!supplierId || !upcValue) { return { success: false }; } - - // Mark this row as being validated - setValidatingUpcRows((prev: number[]) => { - return [...prev, rowIndex]; - }); - + // Check cache first const cacheKey = `${supplierId}-${upcValue}`; if (processedUpcMapRef.current.has(cacheKey)) { const cachedItemNumber = processedUpcMapRef.current.get(cacheKey); - if (cachedItemNumber) { - // Update with cached item number setUpcValidationResults(prev => { const newResults = new Map(prev); newResults.set(rowIndex, { itemNumber: cachedItemNumber }); return newResults; }); - - // Remove from validating state - setValidatingUpcRows(prev => prev.filter(idx => idx !== rowIndex)); - return { success: true, itemNumber: cachedItemNumber }; } - - // Remove from validating state - setValidatingUpcRows(prev => prev.filter(idx => idx !== rowIndex)); return { success: false }; } + + // Add to validation queue + validationQueueRef.current.push({ rowIndex, supplierId, upcValue }); + setValidatingUpcRows(prev => [...prev, rowIndex]); - // Make API call to validate UPC - const apiResult = await fetchProductByUpc(supplierId, upcValue); - - // Remove from validating state now that call is complete - setValidatingUpcRows(prev => prev.filter(idx => idx !== rowIndex)); - - if (apiResult.error) { - // Handle error case - if (apiResult.message && apiResult.message.includes('already exists') && apiResult.data?.itemNumber) { - // UPC already exists - update with existing item number - processedUpcMapRef.current.set(cacheKey, apiResult.data.itemNumber); - - setUpcValidationResults(prev => { - const newResults = new Map(prev); - if (apiResult.data?.itemNumber) { - newResults.set(rowIndex, { itemNumber: apiResult.data.itemNumber }); - } - return newResults; - }); - - return { success: true, itemNumber: apiResult.data.itemNumber }; - } else { - // Other error - show validation error - setValidationErrors(prev => { - const newErrors = new Map(prev); - const rowErrors = {...(newErrors.get(rowIndex) || {})}; - - rowErrors.upc = [{ - message: apiResult.message || 'Invalid UPC', - level: 'error', - source: 'validation' - }]; - - newErrors.set(rowIndex, rowErrors); - return newErrors; - }); - - // Update data errors too - setData(prevData => { - const newData = [...prevData]; - - if (newData[rowIndex]) { - const rowErrors = {...(newData[rowIndex].__errors || {})}; - - rowErrors.upc = [{ - message: apiResult.message || 'Invalid UPC', - level: 'error', - source: 'validation' - }]; - - newData[rowIndex] = { - ...newData[rowIndex], - __errors: rowErrors - }; - } - - return newData; - }); - - return { success: false }; - } - } else if (apiResult.data && apiResult.data.itemNumber) { - // Success case - update with new item number - processedUpcMapRef.current.set(cacheKey, apiResult.data.itemNumber); - - setUpcValidationResults(prev => { - const newResults = new Map(prev); - if (apiResult.data?.itemNumber) { - newResults.set(rowIndex, { itemNumber: apiResult.data.itemNumber }); - } - return newResults; - }); - - // Clear UPC errors - setValidationErrors(prev => { - const newErrors = new Map(prev); - const rowErrors = {...(newErrors.get(rowIndex) || {})}; - - if ('upc' in rowErrors) { - delete rowErrors.upc; - } - - if (Object.keys(rowErrors).length > 0) { - newErrors.set(rowIndex, rowErrors); - } else { - newErrors.delete(rowIndex); - } - - return newErrors; - }); - - return { success: true, itemNumber: apiResult.data.itemNumber }; - } - - return { success: false }; + // Trigger batch processing + debouncedProcessBatch(); + + return { success: true }; } catch (error) { - console.error(`Error validating UPC for row ${rowIndex}:`, error); - - // Remove from validating state on error - setValidatingUpcRows((prev: number[]) => prev.filter((idx: number) => idx !== rowIndex)); - + console.error('Error in validateUpc:', error); return { success: false }; } - }, [fetchProductByUpc, setValidatingUpcRows, setUpcValidationResults, setValidationErrors, setData]); + }, [debouncedProcessBatch]); // Track which cells are currently being validated - allows targeted re-rendering const isValidatingUpc = useCallback((rowIndex: number) => { @@ -912,6 +823,7 @@ useEffect(() => { // Update all rows below with the same value using the existing updateRow function // This ensures all validation logic runs consistently for (let i = rowIndex + 1; i < data.length; i++) { + // Just use updateRow which will handle validation with proper timing updateRow(i, key, sourceValue); } }, [data, updateRow]); @@ -1450,7 +1362,7 @@ useEffect(() => { initialValidationDoneRef.current = true; // Run item number uniqueness validation after basic validation - validateItemNumberUniqueness(); + validateUniqueItemNumbers(); } }); }; @@ -1460,80 +1372,6 @@ useEffect(() => { }; // Function to perform UPC validations asynchronously - const runUPCValidation = async () => { - console.log('Starting UPC validation'); - - // Collect rows that need UPC validation - const rowsWithUpc = []; - for (let rowIndex = 0; rowIndex < data.length; rowIndex++) { - const row = data[rowIndex] as Record; - if (row.upc && row.supplier) { - rowsWithUpc.push({ rowIndex, upc: row.upc, supplier: row.supplier }); - } - } - - console.log(`Found ${rowsWithUpc.length} rows with UPC and supplier`); - const BATCH_SIZE = 3; - - for (let i = 0; i < rowsWithUpc.length; i += BATCH_SIZE) { - const batch = rowsWithUpc.slice(i, i + BATCH_SIZE); - await Promise.all(batch.map(async ({ rowIndex, upc, supplier }) => { - try { - const cacheKey = `${supplier}-${upc}`; - if (processedUpcMapRef.current.has(cacheKey)) { - const cachedItemNumber = processedUpcMapRef.current.get(cacheKey); - if (cachedItemNumber) { - setUpcValidationResults(prev => { - const newResults = new Map(prev); - newResults.set(rowIndex, { itemNumber: cachedItemNumber }); - return newResults; - }); - } - return; - } - - console.log(`Validating UPC: ${upc} for supplier: ${supplier}`); - const apiResult = await fetchProductByUpc(supplier, upc); - if (apiResult && !apiResult.error && apiResult.data?.itemNumber) { - const itemNumber = apiResult.data.itemNumber; - processedUpcMapRef.current.set(cacheKey, itemNumber); - setUpcValidationResults(prev => { - const newResults = new Map(prev); - newResults.set(rowIndex, { itemNumber }); - return newResults; - }); - } else if (apiResult.error && apiResult.message !== 'UPC not found') { - setValidationErrors(prev => { - const newErrors = new Map(prev); - const rowErrors = newErrors.get(rowIndex) || {}; - newErrors.set(rowIndex, { - ...rowErrors, - upc: [{ - message: apiResult.message || 'Invalid UPC', - level: 'error', - source: 'validation' - }] - }); - return newErrors; - }); - setRowValidationStatus(prev => { - const newStatus = new Map(prev); - newStatus.set(rowIndex, 'error'); - return newStatus; - }); - } - } catch (error) { - console.error('Error validating UPC:', error); - } - })); - - if (i + BATCH_SIZE < rowsWithUpc.length) { - await new Promise(resolve => setTimeout(resolve, 100)); - } - } - - console.log('UPC validation complete'); - }; // Run basic validations immediately to update UI runBasicValidation();