2 Commits

Author SHA1 Message Date
0068d77ad9 Optimize validation table 2025-03-10 21:59:24 -04:00
b69182e2c7 Fix validation table scroll location saving issues 2025-03-10 00:17:55 -04:00
8 changed files with 1029 additions and 944 deletions

View File

@@ -203,6 +203,313 @@ We removed scroll position management code from:
Result: This did not fix the issue either.
### 8. Simple Scroll Position Management with Event Listeners
```typescript
// Create a ref to store scroll position
const scrollPosition = useRef({ left: 0, top: 0 });
const tableContainerRef = useRef<HTMLDivElement>(null);
// Save scroll position when scrolling
const handleScroll = useCallback(() => {
if (tableContainerRef.current) {
scrollPosition.current = {
left: tableContainerRef.current.scrollLeft,
top: tableContainerRef.current.scrollTop
};
}
}, []);
// Add scroll listener
useEffect(() => {
const container = tableContainerRef.current;
if (container) {
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}
}, [handleScroll]);
// Restore scroll position after data changes
useLayoutEffect(() => {
const container = tableContainerRef.current;
if (container) {
const { left, top } = scrollPosition.current;
if (left > 0 || top > 0) {
container.scrollLeft = left;
container.scrollTop = top;
}
}
}, [data]);
```
Result: Still did not maintain scroll position during updates.
### 9. Memoized Scroll Container Component
```typescript
// Create a stable scroll container that won't re-render with the table
const ScrollContainer = React.memo(({ children }: { children: React.ReactNode }) => {
const containerRef = useRef<HTMLDivElement>(null);
const scrollPosition = useRef({ left: 0, top: 0 });
const handleScroll = useCallback(() => {
if (containerRef.current) {
scrollPosition.current = {
left: containerRef.current.scrollLeft,
top: containerRef.current.scrollTop
};
}
}, []);
useEffect(() => {
const container = containerRef.current;
if (container) {
// Set initial scroll position if it exists
if (scrollPosition.current.left > 0 || scrollPosition.current.top > 0) {
container.scrollLeft = scrollPosition.current.left;
container.scrollTop = scrollPosition.current.top;
}
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}
}, [handleScroll]);
return (
<div ref={containerRef} className="overflow-auto max-h-[calc(100vh-300px)]">
{children}
</div>
);
});
```
Result: Still did not maintain scroll position during updates, even with a memoized container.
### 10. Using TanStack Table State Management
```typescript
// Track scroll state in the table instance
const [scrollState, setScrollState] = useState({ scrollLeft: 0, scrollTop: 0 });
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
state: {
rowSelection,
// Include scroll position in table state
scrollLeft: scrollState.scrollLeft,
scrollTop: scrollState.scrollTop
},
onStateChange: (updater) => {
if (typeof updater === 'function') {
const newState = updater({
rowSelection,
scrollLeft: scrollState.scrollLeft,
scrollTop: scrollState.scrollTop
});
if ('scrollLeft' in newState || 'scrollTop' in newState) {
setScrollState({
scrollLeft: newState.scrollLeft ?? scrollState.scrollLeft,
scrollTop: newState.scrollTop ?? scrollState.scrollTop
});
}
}
}
});
// Handle scroll events
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
const target = event.target as HTMLDivElement;
setScrollState({
scrollLeft: target.scrollLeft,
scrollTop: target.scrollTop
});
}, []);
// Restore scroll position after updates
useLayoutEffect(() => {
if (tableContainerRef.current) {
tableContainerRef.current.scrollLeft = scrollState.scrollLeft;
tableContainerRef.current.scrollTop = scrollState.scrollTop;
}
}, [data, scrollState]);
```
Result: Still did not maintain scroll position during updates, even with table state management.
### 11. Using CSS Sticky Positioning
```typescript
return (
<div className="relative max-h-[calc(100vh-300px)] overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
{table.getFlatHeaders().map((header) => (
<TableHead
key={header.id}
style={{
width: `${header.getSize()}px`,
minWidth: `${header.getSize()}px`,
position: 'sticky',
top: 0,
backgroundColor: 'inherit'
}}
>
{/* Header content */}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{/* Table body content */}
</TableBody>
</Table>
</div>
);
```
Result: Still did not maintain scroll position during updates, even with native CSS scrolling.
### 12. Optimized Memoization with Object.is
```typescript
// Memoize data structures to prevent unnecessary re-renders
const memoizedData = useMemo(() => data, [data]);
const memoizedValidationErrors = useMemo(() => validationErrors, [validationErrors]);
const memoizedValidatingCells = useMemo(() => validatingCells, [validatingCells]);
const memoizedItemNumbers = useMemo(() => itemNumbers, [itemNumbers]);
// Use Object.is for more efficient comparisons
export default React.memo(ValidationTable, (prev, next) => {
if (!Object.is(prev.data.length, next.data.length)) return false;
if (prev.validationErrors.size !== next.validationErrors.size) return false;
for (const [key, value] of prev.validationErrors) {
if (!next.validationErrors.has(key)) return false;
if (!Object.is(value, next.validationErrors.get(key))) return false;
}
// ... more optimized comparisons ...
});
```
Result: Caused the page to crash with "TypeError: undefined has no properties" in the MemoizedCell component.
### 13. Simplified Component Structure
```typescript
const ValidationTable = <T extends string>({
data,
fields,
rowSelection,
setRowSelection,
updateRow,
validationErrors,
// ... other props
}) => {
const tableContainerRef = useRef<HTMLDivElement>(null);
const lastScrollPosition = useRef({ left: 0, top: 0 });
// Simple scroll position management
const handleScroll = useCallback(() => {
if (tableContainerRef.current) {
lastScrollPosition.current = {
left: tableContainerRef.current.scrollLeft,
top: tableContainerRef.current.scrollTop
};
}
}, []);
useEffect(() => {
const container = tableContainerRef.current;
if (container) {
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}
}, [handleScroll]);
useLayoutEffect(() => {
const container = tableContainerRef.current;
if (container) {
const { left, top } = lastScrollPosition.current;
if (left > 0 || top > 0) {
requestAnimationFrame(() => {
if (container) {
container.scrollLeft = left;
container.scrollTop = top;
}
});
}
}
}, [data]);
return (
<div ref={tableContainerRef} className="overflow-auto max-h-[calc(100vh-300px)]">
<Table>
{/* ... table content ... */}
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className={cn(
"hover:bg-muted/50",
row.getIsSelected() ? "bg-muted/50" : "",
validationErrors.get(data.indexOf(row.original)) ? "bg-red-50/40" : ""
)}
>
{/* ... row content ... */}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
```
Result: Still did not maintain scroll position during updates. However, this implementation restored the subtle red highlight on rows with validation errors, which is a useful visual indicator that should be preserved in future attempts.
### 14. Portal-Based Scroll Container
```typescript
// Create a stable container outside of React's control
const createStableContainer = () => {
const containerId = 'validation-table-container';
let container = document.getElementById(containerId);
if (!container) {
container = document.createElement('div');
container.id = containerId;
container.className = 'overflow-auto';
container.style.maxHeight = 'calc(100vh - 300px)';
document.body.appendChild(container);
}
return container;
};
const ValidationTable = <T extends string>({...props}) => {
const [container] = useState(createStableContainer);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => {
if (container && container.parentNode) {
container.parentNode.removeChild(container);
}
};
}, [container]);
// ... table configuration ...
return createPortal(content, container);
};
```
Result: The table contents failed to render at all. The portal-based approach to maintain scroll position by moving the scroll container outside of React's control was unsuccessful.
## Current Understanding
The scroll position issue appears to be complex and likely stems from multiple factors:
@@ -214,14 +521,18 @@ The scroll position issue appears to be complex and likely stems from multiple f
## Next Steps to Consider
Potential approaches that haven't been tried yet:
At this point, we have tried multiple approaches without success:
1. Various scroll position management techniques
2. Memoization and optimization strategies
3. Different component structures
4. Portal-based rendering
1. Implement a completely separate scroll container that exists outside of React's rendering cycle
2. Use a third-party virtualized table library that handles scroll position natively
3. Restructure the component hierarchy to minimize re-renders
4. Use the React DevTools profiler to identify which components are causing re-renders
5. Consider simplifying the data structure to reduce the complexity of renders
Given that none of these approaches have fully resolved the issue, it may be worth:
1. Investigating if there are any parent component updates forcing re-renders
2. Profiling the application to identify the exact timing of scroll position resets
3. Considering if the current table implementation could be simplified
4. Exploring if the data update patterns could be optimized to reduce re-renders
## Conclusion
This issue has proven particularly challenging to resolve. The current ValidationTable implementation struggles with scroll position preservation despite multiple different approaches. A more fundamental restructuring of the component or its rendering approach may be necessary.
The scroll position issue has proven resistant to multiple solution attempts. Each approach has either failed to maintain scroll position, introduced new issues, or in some cases (like the portal-based approach) prevented the table from rendering entirely. A deeper investigation into the component lifecycle and data flow may be necessary to identify the root cause.

View File

@@ -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)
);
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import React, { useState, useEffect, useRef, useCallback, useMemo, useLayoutEffect } from 'react'
import { useValidationState, Props } from '../hooks/useValidationState'
import ValidationTable from './ValidationTable'
import { Button } from '@/components/ui/button'
@@ -430,7 +430,10 @@ const ValidationContainer = <T extends string>({
// 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 = <T extends string>({
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;
});
// 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);
// 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);
// 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 = <T extends string>({
// 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 = <T extends string>({
if ((fieldKey === 'upc' || fieldKey === 'barcode') && value && rowData) {
const rowDataAny = rowData as Record<string, any>;
if (rowDataAny.supplier) {
// Mark this row as being validated
setValidatingUpcRows(prev => {
const newSet = new Set(prev);
newSet.add(rowIndex);
return newSet;
});
// 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);
// 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);
// 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 = <T extends string>({
});
}, [data, rowSelection, setData, setRowSelection]);
// Enhanced ValidationTable component that's aware of item numbers
const EnhancedValidationTable = useCallback((props: React.ComponentProps<typeof ValidationTable>) => {
// Create validatingCells set from validatingUpcRows
const validatingCells = useMemo(() => {
const cells = new Set<string>();
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<typeof ValidationTable>) => {
// Create validatingCells set from validatingUpcRows, but only for UPC and item_number fields
// This ensures supplier fields don't disappear during UPC validation
const validatingCells = new Set<string>();
validatingUpcRows.forEach(rowIndex => {
// Only mark the UPC and item_number cells as validating, NOT the supplier
validatingCells.add(`${rowIndex}-upc`);
validatingCells.add(`${rowIndex}-item_number`);
});
// Convert itemNumbers to Map
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 (
<ValidationTable
@@ -833,40 +874,38 @@ const ValidationContainer = <T extends string>({
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 (
<EnhancedValidationTable
data={filteredData}
fields={fields}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
updateRow={updateRow}
validationErrors={validationErrors}
isValidatingUpc={isRowValidatingUpc}
validatingUpcRows={Array.from(validatingUpcRows)}
filters={filters}
templates={templates}
applyTemplate={applyTemplate}
getTemplateDisplayText={getTemplateDisplayText}
validatingCells={new Set()}
itemNumbers={new Map()}
isLoadingTemplates={isLoadingTemplates}
copyDown={copyDown}
/>
);
}, [
// Memoize the rendered validation table
const renderValidationTable = useMemo(() => (
<EnhancedValidationTable
data={filteredData}
fields={fields}
rowSelection={rowSelection}
setRowSelection={handleRowSelectionChange}
updateRow={handleUpdateRow}
validationErrors={validationErrors}
isValidatingUpc={isRowValidatingUpc}
validatingUpcRows={Array.from(validatingUpcRows)}
filters={filters}
templates={templates}
applyTemplate={applyTemplate}
getTemplateDisplayText={getTemplateDisplayText}
validatingCells={new Set()}
itemNumbers={new Map()}
isLoadingTemplates={isLoadingTemplates}
copyDown={handleCopyDown}
/>
), [
EnhancedValidationTable,
filteredData,
fields,
rowSelection,
setRowSelection,
updateRow,
handleRowSelectionChange,
handleUpdateRow,
validationErrors,
isRowValidatingUpc,
validatingUpcRows,
@@ -875,9 +914,57 @@ const ValidationContainer = <T extends string>({
applyTemplate,
getTemplateDisplayText,
isLoadingTemplates,
copyDown
handleCopyDown
]);
// Add scroll container ref at the container level
const scrollContainerRef = useRef<HTMLDivElement>(null);
const lastScrollPosition = useRef({ left: 0, top: 0 });
const isScrolling = useRef(false);
// Memoize scroll handlers
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
if (!isScrolling.current) {
isScrolling.current = true;
const target = event.currentTarget;
lastScrollPosition.current = {
left: target.scrollLeft,
top: target.scrollTop
};
requestAnimationFrame(() => {
isScrolling.current = false;
});
}
}, []);
// Add scroll event listener
useEffect(() => {
const container = scrollContainerRef.current;
if (container) {
container.addEventListener('scroll', handleScroll, { passive: true });
return () => container.removeEventListener('scroll', handleScroll);
}
}, [handleScroll]);
// Restore scroll position after data updates
useLayoutEffect(() => {
const container = scrollContainerRef.current;
if (container) {
const { left, top } = lastScrollPosition.current;
if (left > 0 || top > 0) {
requestAnimationFrame(() => {
if (container) {
container.scrollTo({
left,
top,
behavior: 'auto'
});
}
});
}
}
}, [filteredData]);
return (
<div className="flex flex-col h-[calc(100vh-10rem)] overflow-hidden">
<div className="flex-1 overflow-hidden">
@@ -937,8 +1024,21 @@ const ValidationContainer = <T extends string>({
{/* Main table section */}
<div className="px-8 pb-6 flex-1 min-h-0">
<div className="rounded-md border h-full flex flex-col overflow-hidden">
<div className="flex-1 overflow-auto">
{renderValidationTable}
<div className="flex-1 overflow-hidden">
<div
ref={scrollContainerRef}
className="overflow-auto max-h-[calc(100vh-300px)] w-full"
style={{
willChange: 'transform',
position: 'relative',
WebkitOverflowScrolling: 'touch' // Improve scroll performance on Safari
}}
onScroll={handleScroll}
>
<div className="min-w-max"> {/* Force container to be at least as wide as content */}
{renderValidationTable}
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useRef, useEffect, useLayoutEffect, useCallback } from 'react'
import React, { useMemo, useCallback } from 'react'
import {
useReactTable,
getCoreRowModel,
@@ -49,233 +49,6 @@ interface ValidationTableProps<T extends string> {
[key: string]: any
}
// Make Field type mutable for internal use
type MutableField<T extends string> = {
-readonly [K in keyof Field<T>]: Field<T>[K] extends readonly (infer U)[] ? U[] : Field<T>[K]
}
interface MemoizedCellProps<T extends string = string> {
field: MutableField<T>
value: any
rowIndex: number
updateRow: (rowIndex: number, key: string, value: any) => void
validationErrors: Map<number, Record<string, ErrorType[]>>
validatingCells: Set<string>
itemNumbers: Map<number, string>
width: number
copyDown: (rowIndex: number, key: string) => void
}
// Memoized cell component that only updates when its specific data changes
const MemoizedCell = React.memo(({
field,
value,
rowIndex,
updateRow,
validationErrors,
validatingCells,
itemNumbers,
width,
copyDown
}: MemoizedCellProps) => {
const rowErrors = validationErrors.get(rowIndex) || {};
const fieldErrors = rowErrors[String(field.key)] || [];
const isValidating = validatingCells.has(`${rowIndex}-${field.key}`);
// Only compute options when needed for select/multi-select fields
const options = useMemo(() => {
if (field.fieldType.type === 'select' || field.fieldType.type === 'multi-select') {
return Array.from((field.fieldType as any).options || []);
}
return [];
}, [field.fieldType]);
// Memoize the onChange handler to prevent unnecessary re-renders
const handleChange = useCallback((newValue: any) => {
updateRow(rowIndex, field.key, newValue);
}, [updateRow, rowIndex, field.key]);
// Memoize the copyDown handler
const handleCopyDown = useCallback(() => {
copyDown(rowIndex, field.key);
}, [copyDown, rowIndex, field.key]);
return (
<ValidationCell
field={field}
value={value}
onChange={handleChange}
errors={fieldErrors}
isValidating={isValidating}
fieldKey={String(field.key)}
options={options}
itemNumber={itemNumbers.get(rowIndex)}
width={width}
rowIndex={rowIndex}
copyDown={handleCopyDown}
/>
);
}, (prev, next) => {
const fieldKey = String(prev.field.key);
// For item_number fields, only update if the item number or validation state changes
if (fieldKey === 'item_number') {
const prevItemNumber = prev.itemNumbers.get(prev.rowIndex);
const nextItemNumber = next.itemNumbers.get(next.rowIndex);
const prevValidating = prev.validatingCells.has(`${prev.rowIndex}-item_number`);
const nextValidating = next.validatingCells.has(`${next.rowIndex}-item_number`);
return (
prevItemNumber === nextItemNumber &&
prevValidating === nextValidating &&
prev.value === next.value
);
}
// For UPC fields, only update if the value or validation state changes
if (fieldKey === 'upc' || fieldKey === 'barcode') {
const prevValidating = prev.validatingCells.has(`${prev.rowIndex}-${fieldKey}`);
const nextValidating = next.validatingCells.has(`${next.rowIndex}-${fieldKey}`);
return (
prev.value === next.value &&
prevValidating === nextValidating
);
}
// For other fields, only update if the value or errors change
const prevErrors = prev.validationErrors.get(prev.rowIndex)?.[fieldKey];
const nextErrors = next.validationErrors.get(next.rowIndex)?.[fieldKey];
// For select/multi-select fields, also check if options changed
if (prev.field.fieldType.type === 'select' || prev.field.fieldType.type === 'multi-select') {
const prevOptions = (prev.field.fieldType as any).options;
const nextOptions = (next.field.fieldType as any).options;
// If options length changed, we need to re-render
if (prevOptions?.length !== nextOptions?.length) {
return false;
}
}
return (
prev.value === next.value &&
JSON.stringify(prevErrors) === JSON.stringify(nextErrors)
);
});
MemoizedCell.displayName = 'MemoizedCell';
interface MemoizedRowProps {
row: RowData<string>;
fields: readonly {
readonly label: string;
readonly key: string;
readonly description?: string;
readonly alternateMatches?: readonly string[];
readonly validations?: readonly any[];
readonly fieldType: any;
readonly example?: string;
readonly width?: number;
readonly disabled?: boolean;
}[];
updateRow: (rowIndex: number, key: string, value: any) => void;
validationErrors: Map<number, Record<string, ErrorType[]>>;
validatingCells: Set<string>;
itemNumbers: Map<number, string>;
options?: { [key: string]: any[] };
rowIndex: number;
isSelected: boolean;
copyDown: (rowIndex: number, key: string) => void;
}
const MemoizedRow = React.memo<MemoizedRowProps>(({
row,
fields,
updateRow,
validationErrors,
validatingCells,
itemNumbers,
options = {},
rowIndex,
isSelected,
copyDown
}) => {
return (
<TableRow
key={row.__index || rowIndex}
data-state={isSelected && "selected"}
className={validationErrors.get(rowIndex) ? "bg-red-50/40" : "hover:bg-muted/50"}
>
{fields.map((field) => {
if (field.disabled) return null;
const fieldWidth = field.width || (
field.fieldType.type === "checkbox" ? 80 :
field.fieldType.type === "select" ? 150 :
field.fieldType.type === "multi-select" ? 200 :
(field.fieldType.type === "input" || field.fieldType.type === "multi-input") &&
(field.fieldType as any).multiline ? 300 :
150
);
const isValidating = validatingCells.has(`${rowIndex}-${field.key}`);
// Memoize the copyDown handler
const handleCopyDown = () => {
copyDown(rowIndex, field.key);
};
return (
<ValidationCell
key={String(field.key)}
field={field as Field<string>}
value={row[field.key]}
onChange={(value) => updateRow(rowIndex, field.key, value)}
errors={validationErrors.get(rowIndex)?.[String(field.key)] || []}
isValidating={isValidating}
fieldKey={String(field.key)}
options={options[String(field.key)] || []}
width={fieldWidth}
rowIndex={rowIndex}
itemNumber={itemNumbers.get(rowIndex)}
copyDown={handleCopyDown}
/>
);
})}
</TableRow>
);
}, (prev, next) => {
// Compare row data
const prevRowStr = JSON.stringify(prev.row);
const nextRowStr = JSON.stringify(next.row);
if (prevRowStr !== nextRowStr) return false;
// Compare validation errors for this row
const prevErrors = prev.validationErrors.get(prev.rowIndex);
const nextErrors = next.validationErrors.get(next.rowIndex);
if (JSON.stringify(prevErrors) !== JSON.stringify(nextErrors)) return false;
// Compare validation state for this row's cells
const prevValidatingCells = Array.from(prev.validatingCells)
.filter(key => key.startsWith(`${prev.rowIndex}-`));
const nextValidatingCells = Array.from(next.validatingCells)
.filter(key => key.startsWith(`${next.rowIndex}-`));
if (JSON.stringify(prevValidatingCells) !== JSON.stringify(nextValidatingCells)) return false;
// Compare item numbers for this row
const prevItemNumber = prev.itemNumbers.get(prev.rowIndex);
const nextItemNumber = next.itemNumbers.get(next.rowIndex);
if (prevItemNumber !== nextItemNumber) return false;
// Compare selection state
if (prev.isSelected !== next.isSelected) return false;
return true;
});
MemoizedRow.displayName = 'MemoizedRow';
const ValidationTable = <T extends string>({
data,
fields,
@@ -294,75 +67,22 @@ const ValidationTable = <T extends string>({
}: ValidationTableProps<T>) => {
const { translations } = useRsi<T>();
// Create a global scroll position manager
const scrollManager = useRef({
windowX: 0,
windowY: 0,
containerLeft: 0,
containerTop: 0,
isScrolling: false,
// Memoize the selection column with stable callback
const handleSelectAll = useCallback((value: boolean, table: any) => {
table.toggleAllPageRowsSelected(!!value);
}, []);
// Save current scroll positions
save: function() {
this.windowX = window.scrollX;
this.windowY = window.scrollY;
if (tableContainerRef.current) {
this.containerLeft = tableContainerRef.current.scrollLeft;
this.containerTop = tableContainerRef.current.scrollTop;
}
},
const handleRowSelect = useCallback((value: boolean, row: any) => {
row.toggleSelected(!!value);
}, []);
// Restore saved scroll positions
restore: function() {
if (this.isScrolling) return;
this.isScrolling = true;
// Restore window scroll
window.scrollTo(this.windowX, this.windowY);
// Restore container scroll
if (tableContainerRef.current) {
tableContainerRef.current.scrollLeft = this.containerLeft;
tableContainerRef.current.scrollTop = this.containerTop;
}
// Reset flag after a short delay
setTimeout(() => {
this.isScrolling = false;
}, 50);
}
});
// Table container ref
const tableContainerRef = useRef<HTMLDivElement>(null);
// Save scroll position before any potential re-render
useLayoutEffect(() => {
scrollManager.current.save();
// Restore after render
return () => {
requestAnimationFrame(() => {
scrollManager.current.restore();
});
};
});
// Also restore on data changes
useEffect(() => {
requestAnimationFrame(() => {
scrollManager.current.restore();
});
}, [data]);
// Memoize the selection column
const selectionColumn = useMemo((): ColumnDef<RowData<T>, any> => ({
id: 'select',
header: ({ table }) => (
<div className="flex h-full items-center justify-center py-2">
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
onCheckedChange={(value) => handleSelectAll(!!value, table)}
aria-label="Select all"
/>
</div>
@@ -371,7 +91,7 @@ const ValidationTable = <T extends string>({
<div className="flex h-[40px] items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onCheckedChange={(value) => handleRowSelect(!!value, row)}
aria-label="Select row"
/>
</div>
@@ -379,9 +99,14 @@ const ValidationTable = <T extends string>({
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<RowData<T>, any> => ({
accessorKey: '__template',
header: 'Template',
@@ -391,8 +116,6 @@ const ValidationTable = <T extends string>({
const defaultBrand = row.original.company || undefined;
const rowIndex = data.findIndex(r => r === row.original);
console.log(`Template cell for row ${row.id}, index ${rowIndex}`);
return (
<TableCell className="p-1" style={{ width: '200px', minWidth: '200px' }}>
{isLoadingTemplates ? (
@@ -404,9 +127,7 @@ const ValidationTable = <T extends string>({
<SearchableTemplateSelect
templates={templates}
value={templateValue || ''}
onValueChange={(value) => {
applyTemplate(value, [rowIndex]);
}}
onValueChange={(value) => handleTemplateChange(value, rowIndex)}
getTemplateDisplayText={getTemplateDisplayText}
defaultBrand={defaultBrand}
/>
@@ -414,9 +135,19 @@ const ValidationTable = <T extends string>({
</TableCell>
);
}
}), [templates, applyTemplate, getTemplateDisplayText, isLoadingTemplates, data]);
}), [templates, handleTemplateChange, getTemplateDisplayText, isLoadingTemplates, data]);
// Memoize field columns
// Memoize the field update handler
const handleFieldUpdate = useCallback((rowIndex: number, fieldKey: T, value: any) => {
updateRow(rowIndex, fieldKey, value);
}, [updateRow]);
// Memoize the copyDown handler
const handleCopyDown = useCallback((rowIndex: number, fieldKey: string) => {
copyDown(rowIndex, fieldKey);
}, [copyDown]);
// Memoize field columns with stable handlers
const fieldColumns = useMemo(() => fields.map((field): ColumnDef<RowData<T>, any> | null => {
if (field.disabled) return null;
@@ -433,27 +164,24 @@ const ValidationTable = <T extends string>({
accessorKey: String(field.key),
header: field.label || String(field.key),
size: fieldWidth,
cell: ({ row }) => {
const cellUpdateRow = (rowIndex: number, key: string, value: any) => {
updateRow(rowIndex, key as T, value);
};
return (
<MemoizedCell
field={field as MutableField<T>}
value={row.original[field.key as keyof typeof row.original]}
rowIndex={row.index}
updateRow={cellUpdateRow}
validationErrors={validationErrors}
validatingCells={validatingCells}
itemNumbers={itemNumbers}
width={fieldWidth}
copyDown={(rowIndex, key) => copyDown(rowIndex, key as T)}
/>
);
}
cell: ({ row }) => (
<ValidationCell
field={field}
value={row.original[field.key]}
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)}
options={(field.fieldType as any).options || []}
itemNumber={itemNumbers.get(row.index)}
width={fieldWidth}
rowIndex={row.index}
copyDown={() => handleCopyDown(row.index, field.key)}
/>
)
};
}).filter((col): col is ColumnDef<RowData<T>, any> => col !== null), [fields, validationErrors, validatingCells, itemNumbers, updateRow, copyDown]);
}).filter((col): col is ColumnDef<RowData<T>, any> => col !== null),
[fields, validationErrors, validatingCells, itemNumbers, handleFieldUpdate, handleCopyDown]);
// Combine columns
const columns = useMemo(() => [selectionColumn, templateColumn, ...fieldColumns], [selectionColumn, templateColumn, fieldColumns]);
@@ -466,24 +194,16 @@ const ValidationTable = <T extends string>({
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getRowId: (row) => {
// Prefer __index if available (likely a UUID)
if (row.__index) return row.__index;
// Fall back to position in array
const index = data.indexOf(row);
return String(index);
}
});
// Log selection changes for debugging
useEffect(() => {
const selectedCount = Object.values(rowSelection).filter(v => v === true).length;
const selectedIds = Object.entries(rowSelection)
.filter(([_, selected]) => selected === true)
.map(([id, _]) => id);
console.log(`Row selection updated: ${selectedCount} rows selected, IDs:`, selectedIds);
}, [rowSelection]);
// Calculate total table width for stable horizontal scrolling
const totalWidth = useMemo(() => {
return columns.reduce((total, col) => total + (col.size || 0), 0);
}, [columns]);
// Don't render if no data
if (data.length === 0) {
@@ -499,69 +219,93 @@ const ValidationTable = <T extends string>({
}
return (
<div ref={tableContainerRef} className="overflow-auto max-h-[calc(100vh-300px)]">
<Table>
<TableHeader>
<TableRow>
{table.getFlatHeaders().map((header) => (
<TableHead
key={header.id}
<Table style={{ width: `${totalWidth}px`, tableLayout: 'fixed' }}>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
{table.getFlatHeaders().map((header) => (
<TableHead
key={header.id}
style={{
width: `${header.getSize()}px`,
minWidth: `${header.getSize()}px`,
maxWidth: `${header.getSize()}px`,
position: 'sticky',
top: 0,
backgroundColor: 'inherit',
zIndex: 1
}}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className={cn(
"hover:bg-muted/50",
row.getIsSelected() ? "bg-muted/50" : "",
validationErrors.get(data.indexOf(row.original)) ? "bg-red-50/40" : ""
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: `${header.getSize()}px`,
minWidth: `${header.getSize()}px`
width: `${cell.column.getSize()}px`,
minWidth: `${cell.column.getSize()}px`,
maxWidth: `${cell.column.getSize()}px`
}}
>
{header.id === 'select' ? (
<div className="flex h-full items-center justify-center py-2">
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
) : (
flexRender(header.column.columnDef.header, header.getContext())
)}
</TableHead>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={cn(
"hover:bg-muted/50",
row.getIsSelected() ? "bg-muted/50" : ""
)}
>
{row.getVisibleCells().map((cell) => (
<React.Fragment key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</React.Fragment>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
))}
</TableBody>
</Table>
);
};
export default React.memo(ValidationTable, (prev, next) => {
// Deep compare data
if (JSON.stringify(prev.data) !== JSON.stringify(next.data)) return false;
// Optimize memo comparison
const areEqual = (prev: ValidationTableProps<any>, next: ValidationTableProps<any>) => {
// Check reference equality for simple props first
if (prev.fields !== next.fields) return false;
if (prev.templates !== next.templates) return false;
if (prev.isLoadingTemplates !== next.isLoadingTemplates) return false;
if (prev.filters?.showErrorsOnly !== next.filters?.showErrorsOnly) return false;
// Compare validation errors
if (JSON.stringify(Array.from(prev.validationErrors.entries())) !==
JSON.stringify(Array.from(next.validationErrors.entries()))) return false;
// Check data length and content
if (prev.data.length !== next.data.length) return false;
// Compare filters
if (JSON.stringify(prev.filters) !== JSON.stringify(next.filters)) 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;
// Compare row selection
if (JSON.stringify(prev.rowSelection) !== JSON.stringify(next.rowSelection)) 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;
for (const [key, value] of prev.itemNumbers) {
if (next.itemNumbers.get(key) !== value) return false;
}
return true;
});
};
export default React.memo(ValidationTable, areEqual);

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react'
import React, { useState, useCallback, useDeferredValue, useTransition } from 'react'
import { Field } from '../../../../types'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
@@ -13,6 +13,7 @@ interface InputCellProps<T extends string> {
hasErrors?: boolean
isMultiline?: boolean
isPrice?: boolean
disabled?: boolean
}
const InputCell = <T extends string>({
@@ -22,12 +23,15 @@ const InputCell = <T extends string>({
onEndEdit,
hasErrors,
isMultiline = false,
isPrice = false
isPrice = false,
disabled = false
}: InputCellProps<T>) => {
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 = <T extends string>({
onStartEdit?.()
}, [value, onStartEdit, isPrice])
// Handle blur event
// Handle blur event - use transition for non-critical updates
const handleBlur = useCallback(() => {
setIsEditing(false)
startTransition(() => {
setIsEditing(false)
// Format the value for storage (remove formatting like $ for price)
let processedValue = editValue
// Format the value for storage (remove formatting like $ for price)
let processedValue = deferredEditValue
if (isPrice) {
// Remove any non-numeric characters except decimal point
processedValue = editValue.replace(/[^\d.]/g, '')
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)
// 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<HTMLInputElement | HTMLTextAreaElement>) => {
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 (
<div className={cn(
"px-3 py-2 h-10 rounded-md text-sm w-full",
outlineClass,
hasErrors ? "border-destructive" : "border-input"
)}>
{displayValue}
</div>
);
}
return (
<div className="w-full">
{isMultiline ? (
@@ -125,7 +125,8 @@ const InputCell = <T extends string>({
autoFocus
className={cn(
outlineClass,
hasErrors ? "border-destructive" : ""
hasErrors ? "border-destructive" : "",
isPending ? "opacity-50" : ""
)}
/>
) : (
@@ -137,7 +138,7 @@ const InputCell = <T extends string>({
hasErrors ? "border-destructive" : "border-input"
)}
>
{isPrice ? getDisplayValue() : (value ?? '')}
{displayValue}
</div>
)
)}
@@ -145,13 +146,13 @@ const InputCell = <T extends string>({
)
}
// Memoize the component with a strict comparison function
// Optimize memo comparison to focus on essential props
export default React.memo(InputCell, (prev, next) => {
// Only re-render if these props change
return (
prev.value === next.value &&
prev.hasErrors === next.hasErrors &&
prev.isMultiline === next.isMultiline &&
prev.isPrice === next.isPrice
)
})
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;
});

View File

@@ -26,6 +26,7 @@ interface MultiInputCellProps<T extends string> {
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 = <T extends string>({
separator = ',',
isMultiline = false,
isPrice = false,
options: providedOptions
options: providedOptions,
disabled = false
}: MultiInputCellProps<T>) => {
const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
@@ -180,6 +182,27 @@ const MultiInputCell = <T extends string>({
// 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 (
<div className={cn(
"w-full px-3 py-2 h-10 rounded-md text-sm flex items-center",
"border",
hasErrors ? "border-destructive" : "border-input"
)}>
{displayValue || ""}
</div>
);
}
// If we have a multi-select field with options, use command UI
if (field.fieldType.type === 'multi-select' && selectOptions.length > 0) {
// Get width from field if available, or default to a reasonable value

View File

@@ -2,20 +2,10 @@ import { useState, useRef, useCallback, useMemo } from 'react'
import { Field } from '../../../../types'
import { 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<T extends string> {
field: Field<T>
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 = <T extends string>({
field,
value,
@@ -39,11 +31,27 @@ const SelectCell = <T extends string>({
onStartEdit,
onEndEdit,
hasErrors,
options
options = [],
disabled = false
}: SelectCellProps<T>) => {
const [open, setOpen] = useState(false)
// Ref for the command list to enable scrolling
const commandListRef = useRef<HTMLDivElement>(null)
// State for the open/closed state of the dropdown
const [open, setOpen] = useState(false);
// Ref for the command list
const commandListRef = useRef<HTMLDivElement>(null);
// Controlled state for the internal value - this is key to prevent reopening
const [internalValue, setInternalValue] = useState(value);
// State to track if the value is being processed/validated
const [isProcessing, setIsProcessing] = useState(false);
// Update internal value when prop value changes
React.useEffect(() => {
setInternalValue(value);
// When the value prop changes, it means validation is complete
setIsProcessing(false);
}, [value]);
// Memoize options processing to avoid recalculation on every render
const selectOptions = useMemo(() => {
@@ -62,7 +70,6 @@ const SelectCell = <T extends string>({
}));
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 = <T extends string>({
// 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 = <T extends string>({
}
}, []);
// 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 = <T extends string>({
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) && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
));
}, [selectOptions, value, handleSelect]);
}, [selectOptions, internalValue, handleSelect]);
// If disabled, render a static view
if (disabled) {
const selectedOption = options.find(o => o.value === internalValue);
const displayText = selectedOption ? selectedOption.label : internalValue;
return (
<div className={cn(
"w-full px-3 py-2 h-10 rounded-md text-sm flex items-center",
"border",
hasErrors ? "border-destructive" : "border-input",
isProcessing ? "text-muted-foreground" : ""
)}>
{displayText || ""}
</div>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (isOpen && onStartEdit) onStartEdit();
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -116,21 +162,33 @@ const SelectCell = <T extends string>({
className={cn(
"w-full justify-between font-normal",
"border",
!value && "text-muted-foreground",
!internalValue && "text-muted-foreground",
isProcessing && "text-muted-foreground",
hasErrors ? "border-destructive" : ""
)}
onClick={() => {
setOpen(!open)
if (onStartEdit) onStartEdit()
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen(!open);
if (!open && onStartEdit) onStartEdit();
}}
>
{displayValue}
<span className={isProcessing ? "opacity-70" : ""}>
{displayValue}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput placeholder="Search..." />
<PopoverContent
className="p-0 w-[var(--radix-popover-trigger-width)]"
align="start"
sideOffset={4}
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search..."
className="h-9"
/>
<CommandList
ref={commandListRef}
onWheel={handleWheel}
@@ -147,4 +205,13 @@ const SelectCell = <T extends string>({
)
}
export default SelectCell
// Optimize memo comparison to avoid unnecessary re-renders
export default React.memo(SelectCell, (prev, next) => {
// Only rerender when these critical props change
return (
prev.value === next.value &&
prev.hasErrors === next.hasErrors &&
prev.disabled === next.disabled &&
prev.options === next.options
);
});

View File

@@ -78,6 +78,21 @@ declare global {
// Use a helper to get API URL consistently
export const getApiUrl = () => config.apiUrl;
// Add debounce utility
const DEBOUNCE_DELAY = 300;
const BATCH_SIZE = 5;
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
// Main validation state hook
export const useValidationState = <T extends string>({
initialData,
@@ -165,7 +180,7 @@ export const useValidationState = <T extends string>({
const [isValidating] = useState(false)
const [validationErrors, setValidationErrors] = useState<Map<number, Record<string, ErrorType[]>>>(new Map())
const [rowValidationStatus, setRowValidationStatus] = useState<Map<number, 'pending' | 'validating' | 'validated' | 'error'>>(new Map())
const [validatingCells, setValidatingCells] = useState<Set<string>>(new Set())
const [, setValidatingCells] = useState<Set<string>>(new Set())
// Template state
const [templates, setTemplates] = useState<Template[]>([])
@@ -195,162 +210,118 @@ export const useValidationState = <T extends string>({
// 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<string, number[]>();
// Add batch update state
const pendingUpdatesRef = useRef<{
errors: Map<number, Record<string, ErrorType[]>>,
statuses: Map<number, 'pending' | 'validating' | 'validated' | 'error'>,
data: Array<RowData<T>>
}>({
errors: new Map(),
statuses: new Map(),
data: []
});
// First pass: collect all item numbers and their row indices
data.forEach((row, rowIndex) => {
const itemNumber = row.item_number;
// Optimized batch update function
const flushPendingUpdates = useCallback(() => {
const updates = pendingUpdatesRef.current;
if (updates.errors.size > 0) {
setValidationErrors(prev => {
const newErrors = new Map(prev);
updates.errors.forEach((errors, rowIndex) => {
if (Object.keys(errors).length === 0) {
newErrors.delete(rowIndex);
} else {
newErrors.set(rowIndex, errors);
}
});
return newErrors;
});
updates.errors = new Map();
}
if (updates.statuses.size > 0) {
setRowValidationStatus(prev => {
const newStatuses = new Map(prev);
updates.statuses.forEach((status, rowIndex) => {
newStatuses.set(rowIndex, status);
});
return newStatuses;
});
updates.statuses = new Map();
}
if (updates.data.length > 0) {
setData(prev => {
const newData = [...prev];
updates.data.forEach((row, index) => {
newData[index] = row;
});
return newData;
});
updates.data = [];
}
}, []);
// Debounced flush updates
const debouncedFlushUpdates = useMemo(
() => debounce(flushPendingUpdates, DEBOUNCE_DELAY),
[flushPendingUpdates]
);
// Queue updates instead of immediate setState calls
const queueUpdate = useCallback((rowIndex: number, updates: {
errors?: Record<string, ErrorType[]>,
status?: 'pending' | 'validating' | 'validated' | 'error',
data?: RowData<T>
}) => {
if (updates.errors) {
pendingUpdatesRef.current.errors.set(rowIndex, updates.errors);
}
if (updates.status) {
pendingUpdatesRef.current.statuses.set(rowIndex, updates.status);
}
if (updates.data) {
pendingUpdatesRef.current.data[rowIndex] = updates.data;
}
debouncedFlushUpdates();
}, [debouncedFlushUpdates]);
// Update validateUniqueItemNumbers to use batch updates
const validateUniqueItemNumbers = useCallback(async () => {
const duplicates = new Map<string, number[]>();
const itemNumberMap = new Map<string, number>();
data.forEach((row, index) => {
const itemNumber = row.item_number?.toString();
if (itemNumber) {
if (!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<number, Record<string, ErrorType[]>>();
const statusesToUpdate = new Map<number, 'error' | 'validated'>();
const rowsToUpdate: {rowIndex: number, errors: Record<string, ErrorType[]>}[] = [];
// Process only duplicates
duplicates.forEach(([, rowIndices]) => {
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<ValidationResult> => {
@@ -441,146 +412,86 @@ useEffect(() => {
}
}, []);
// Add batch validation queue
const validationQueueRef = useRef<{rowIndex: number, supplierId: string, upcValue: string}[]>([]);
const isProcessingBatchRef = useRef(false);
// Process validation queue in batches
const processBatchValidation = useCallback(async () => {
if (isProcessingBatchRef.current) return;
if (validationQueueRef.current.length === 0) return;
isProcessingBatchRef.current = true;
const batch = validationQueueRef.current.splice(0, BATCH_SIZE);
try {
await Promise.all(batch.map(async ({ rowIndex, supplierId, upcValue }) => {
// Skip if already validated
const cacheKey = `${supplierId}-${upcValue}`;
if (processedUpcMapRef.current.has(cacheKey)) return;
const result = await fetchProductByUpc(supplierId, upcValue);
if (!result.error && result.data?.itemNumber) {
processedUpcMapRef.current.set(cacheKey, result.data.itemNumber);
setUpcValidationResults(prev => {
const newResults = new Map(prev);
newResults.set(rowIndex, { itemNumber: result.data?.itemNumber || '' });
return newResults;
});
}
}));
} finally {
isProcessingBatchRef.current = false;
// Process next batch if queue not empty
if (validationQueueRef.current.length > 0) {
processBatchValidation();
}
}
}, [fetchProductByUpc]);
// Debounced version of processBatchValidation
const debouncedProcessBatch = useMemo(
() => debounce(processBatchValidation, DEBOUNCE_DELAY),
[processBatchValidation]
);
// Modified validateUpc to use queue
const validateUpc = useCallback(async (rowIndex: number, supplierId: string, upcValue: string): Promise<{ success: boolean, itemNumber?: string }> => {
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 };
}
// Make API call to validate UPC
const apiResult = await fetchProductByUpc(supplierId, upcValue);
// Add to validation queue
validationQueueRef.current.push({ rowIndex, supplierId, upcValue });
setValidatingUpcRows(prev => [...prev, rowIndex]);
// Remove from validating state now that call is complete
setValidatingUpcRows(prev => prev.filter(idx => idx !== rowIndex));
// Trigger batch processing
debouncedProcessBatch();
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 };
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<string, any>;
if (row.upc && row.supplier) {
rowsWithUpc.push({ rowIndex, upc: row.upc, supplier: row.supplier });
}
}
console.log(`Found ${rowsWithUpc.length} rows with UPC and supplier`);
const BATCH_SIZE = 3;
for (let i = 0; i < rowsWithUpc.length; i += BATCH_SIZE) {
const batch = rowsWithUpc.slice(i, i + BATCH_SIZE);
await Promise.all(batch.map(async ({ rowIndex, upc, supplier }) => {
try {
const cacheKey = `${supplier}-${upc}`;
if (processedUpcMapRef.current.has(cacheKey)) {
const cachedItemNumber = processedUpcMapRef.current.get(cacheKey);
if (cachedItemNumber) {
setUpcValidationResults(prev => {
const newResults = new Map(prev);
newResults.set(rowIndex, { itemNumber: cachedItemNumber });
return newResults;
});
}
return;
}
console.log(`Validating UPC: ${upc} for supplier: ${supplier}`);
const apiResult = await fetchProductByUpc(supplier, upc);
if (apiResult && !apiResult.error && apiResult.data?.itemNumber) {
const itemNumber = apiResult.data.itemNumber;
processedUpcMapRef.current.set(cacheKey, itemNumber);
setUpcValidationResults(prev => {
const newResults = new Map(prev);
newResults.set(rowIndex, { itemNumber });
return newResults;
});
} else if (apiResult.error && apiResult.message !== 'UPC not found') {
setValidationErrors(prev => {
const newErrors = new Map(prev);
const rowErrors = newErrors.get(rowIndex) || {};
newErrors.set(rowIndex, {
...rowErrors,
upc: [{
message: apiResult.message || 'Invalid UPC',
level: 'error',
source: 'validation'
}]
});
return newErrors;
});
setRowValidationStatus(prev => {
const newStatus = new Map(prev);
newStatus.set(rowIndex, 'error');
return newStatus;
});
}
} catch (error) {
console.error('Error validating UPC:', error);
}
}));
if (i + BATCH_SIZE < rowsWithUpc.length) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
console.log('UPC validation complete');
};
// Run basic validations immediately to update UI
runBasicValidation();