Compare commits
2 Commits
1c8709f520
...
0068d77ad9
| Author | SHA1 | Date | |
|---|---|---|---|
| 0068d77ad9 | |||
| b69182e2c7 |
@@ -203,6 +203,313 @@ We removed scroll position management code from:
|
|||||||
|
|
||||||
Result: This did not fix the issue either.
|
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
|
## Current Understanding
|
||||||
|
|
||||||
The scroll position issue appears to be complex and likely stems from multiple factors:
|
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
|
## 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
|
Given that none of these approaches have fully resolved the issue, it may be worth:
|
||||||
2. Use a third-party virtualized table library that handles scroll position natively
|
1. Investigating if there are any parent component updates forcing re-renders
|
||||||
3. Restructure the component hierarchy to minimize re-renders
|
2. Profiling the application to identify the exact timing of scroll position resets
|
||||||
4. Use the React DevTools profiler to identify which components are causing re-renders
|
3. Considering if the current table implementation could be simplified
|
||||||
5. Consider simplifying the data structure to reduce the complexity of renders
|
4. Exploring if the data update patterns could be optimized to reduce re-renders
|
||||||
|
|
||||||
## Conclusion
|
## 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.
|
||||||
@@ -376,7 +376,8 @@ export default React.memo(ValidationCell, (prev, next) => {
|
|||||||
return (
|
return (
|
||||||
prev.value === next.value &&
|
prev.value === next.value &&
|
||||||
prevErrorsStr === nextErrorsStr &&
|
prevErrorsStr === nextErrorsStr &&
|
||||||
prevOptionsStr === nextOptionsStr
|
// Only do the deep comparison if the references are different
|
||||||
|
(prev.options === next.options || prevOptionsStr === nextOptionsStr)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useValidationState, Props } from '../hooks/useValidationState'
|
||||||
import ValidationTable from './ValidationTable'
|
import ValidationTable from './ValidationTable'
|
||||||
import { Button } from '@/components/ui/button'
|
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
|
// Fetch product lines for the new company if rowData has __index
|
||||||
if (rowData && rowData.__index) {
|
if (rowData && rowData.__index) {
|
||||||
|
// Use setTimeout to make this non-blocking
|
||||||
|
setTimeout(async () => {
|
||||||
await fetchProductLines(rowData.__index, value.toString());
|
await fetchProductLines(rowData.__index, value.toString());
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +443,10 @@ const ValidationContainer = <T extends string>({
|
|||||||
if (rowDataAny.upc || rowDataAny.barcode) {
|
if (rowDataAny.upc || rowDataAny.barcode) {
|
||||||
const upcValue = rowDataAny.upc || rowDataAny.barcode;
|
const upcValue = rowDataAny.upc || rowDataAny.barcode;
|
||||||
|
|
||||||
|
// 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
|
// Mark this row as being validated
|
||||||
setValidatingUpcRows(prev => {
|
setValidatingUpcRows(prev => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
@@ -452,8 +459,10 @@ const ValidationContainer = <T extends string>({
|
|||||||
|
|
||||||
// Use supplier ID (the value being set) to validate UPC
|
// Use supplier ID (the value being set) to validate UPC
|
||||||
await validateUpc(rowIndex, value.toString(), upcValue.toString());
|
await validateUpc(rowIndex, value.toString(), upcValue.toString());
|
||||||
|
} catch (error) {
|
||||||
// Update validation state
|
console.error('Error validating UPC:', error);
|
||||||
|
} finally {
|
||||||
|
// Always clean up validation state, even if there was an error
|
||||||
setValidatingUpcRows(prev => {
|
setValidatingUpcRows(prev => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
newSet.delete(rowIndex);
|
newSet.delete(rowIndex);
|
||||||
@@ -463,6 +472,8 @@ const ValidationContainer = <T extends string>({
|
|||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}, 200); // Slight delay to let the UI update first
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If updating line field, fetch sublines
|
// If updating line field, fetch sublines
|
||||||
@@ -481,7 +492,10 @@ const ValidationContainer = <T extends string>({
|
|||||||
|
|
||||||
// Fetch sublines for the new line if rowData has __index
|
// Fetch sublines for the new line if rowData has __index
|
||||||
if (rowData && rowData.__index) {
|
if (rowData && rowData.__index) {
|
||||||
|
// Use setTimeout to make this non-blocking
|
||||||
|
setTimeout(async () => {
|
||||||
await fetchSublines(rowData.__index, value.toString());
|
await fetchSublines(rowData.__index, value.toString());
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,6 +503,9 @@ const ValidationContainer = <T extends string>({
|
|||||||
if ((fieldKey === 'upc' || fieldKey === 'barcode') && value && rowData) {
|
if ((fieldKey === 'upc' || fieldKey === 'barcode') && value && rowData) {
|
||||||
const rowDataAny = rowData as Record<string, any>;
|
const rowDataAny = rowData as Record<string, any>;
|
||||||
if (rowDataAny.supplier) {
|
if (rowDataAny.supplier) {
|
||||||
|
// Run UPC validation in a non-blocking way
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
// Mark this row as being validated
|
// Mark this row as being validated
|
||||||
setValidatingUpcRows(prev => {
|
setValidatingUpcRows(prev => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
@@ -501,8 +518,10 @@ const ValidationContainer = <T extends string>({
|
|||||||
|
|
||||||
// Use supplier ID from the row data (NOT company ID) to validate UPC
|
// Use supplier ID from the row data (NOT company ID) to validate UPC
|
||||||
await validateUpc(rowIndex, rowDataAny.supplier.toString(), value.toString());
|
await validateUpc(rowIndex, rowDataAny.supplier.toString(), value.toString());
|
||||||
|
} catch (error) {
|
||||||
// Update validation state
|
console.error('Error validating UPC:', error);
|
||||||
|
} finally {
|
||||||
|
// Always clean up validation state, even if there was an error
|
||||||
setValidatingUpcRows(prev => {
|
setValidatingUpcRows(prev => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
newSet.delete(rowIndex);
|
newSet.delete(rowIndex);
|
||||||
@@ -512,6 +531,8 @@ const ValidationContainer = <T extends string>({
|
|||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}, 200); // Slight delay to let the UI update first
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [data, filteredData, updateRow, fetchProductLines, fetchSublines, validateUpc, setData]);
|
}, [data, filteredData, updateRow, fetchProductLines, fetchSublines, validateUpc, setData]);
|
||||||
|
|
||||||
@@ -792,30 +813,51 @@ const ValidationContainer = <T extends string>({
|
|||||||
});
|
});
|
||||||
}, [data, rowSelection, setData, setRowSelection]);
|
}, [data, rowSelection, setData, setRowSelection]);
|
||||||
|
|
||||||
// Enhanced ValidationTable component that's aware of item numbers
|
// Memoize handlers
|
||||||
const EnhancedValidationTable = useCallback((props: React.ComponentProps<typeof ValidationTable>) => {
|
const handleFiltersChange = useCallback((newFilters: any) => {
|
||||||
// Create validatingCells set from validatingUpcRows
|
updateFilters(newFilters);
|
||||||
const validatingCells = useMemo(() => {
|
}, [updateFilters]);
|
||||||
const cells = new Set<string>();
|
|
||||||
validatingUpcRows.forEach(rowIndex => {
|
const handleRowSelectionChange = useCallback((newSelection: RowSelectionState) => {
|
||||||
cells.add(`${rowIndex}-upc`);
|
setRowSelection(newSelection);
|
||||||
cells.add(`${rowIndex}-item_number`);
|
}, [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`);
|
||||||
});
|
});
|
||||||
return cells;
|
|
||||||
}, [validatingUpcRows]);
|
|
||||||
|
|
||||||
// Convert itemNumbers to Map
|
// Convert itemNumbers to Map
|
||||||
const itemNumbersMap = useMemo(() =>
|
const itemNumbersMap = new Map(Object.entries(itemNumbers).map(([key, value]) => [parseInt(key), value]));
|
||||||
new Map(Object.entries(itemNumbers).map(([key, value]) => [parseInt(key), value])),
|
|
||||||
[itemNumbers]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Merge the item numbers with the data for display purposes only
|
// Merge the item numbers with the data for display purposes only
|
||||||
const enhancedData = useMemo(() => {
|
const enhancedData = props.data.map((row: any, index: number) => {
|
||||||
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]) {
|
if (itemNumbers[index]) {
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
@@ -824,7 +866,6 @@ const ValidationContainer = <T extends string>({
|
|||||||
}
|
}
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
}, [props.data, itemNumbers]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ValidationTable
|
<ValidationTable
|
||||||
@@ -833,20 +874,19 @@ const ValidationContainer = <T extends string>({
|
|||||||
validatingCells={validatingCells}
|
validatingCells={validatingCells}
|
||||||
itemNumbers={itemNumbersMap}
|
itemNumbers={itemNumbersMap}
|
||||||
isLoadingTemplates={isLoadingTemplates}
|
isLoadingTemplates={isLoadingTemplates}
|
||||||
copyDown={copyDown}
|
copyDown={handleCopyDown}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, [validatingUpcRows, itemNumbers, isLoadingTemplates, copyDown]);
|
}), [validatingUpcRows, itemNumbers, isLoadingTemplates, handleCopyDown]);
|
||||||
|
|
||||||
// Memoize the ValidationTable to prevent unnecessary re-renders
|
// Memoize the rendered validation table
|
||||||
const renderValidationTable = useMemo(() => {
|
const renderValidationTable = useMemo(() => (
|
||||||
return (
|
|
||||||
<EnhancedValidationTable
|
<EnhancedValidationTable
|
||||||
data={filteredData}
|
data={filteredData}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
setRowSelection={setRowSelection}
|
setRowSelection={handleRowSelectionChange}
|
||||||
updateRow={updateRow}
|
updateRow={handleUpdateRow}
|
||||||
validationErrors={validationErrors}
|
validationErrors={validationErrors}
|
||||||
isValidatingUpc={isRowValidatingUpc}
|
isValidatingUpc={isRowValidatingUpc}
|
||||||
validatingUpcRows={Array.from(validatingUpcRows)}
|
validatingUpcRows={Array.from(validatingUpcRows)}
|
||||||
@@ -857,16 +897,15 @@ const ValidationContainer = <T extends string>({
|
|||||||
validatingCells={new Set()}
|
validatingCells={new Set()}
|
||||||
itemNumbers={new Map()}
|
itemNumbers={new Map()}
|
||||||
isLoadingTemplates={isLoadingTemplates}
|
isLoadingTemplates={isLoadingTemplates}
|
||||||
copyDown={copyDown}
|
copyDown={handleCopyDown}
|
||||||
/>
|
/>
|
||||||
);
|
), [
|
||||||
}, [
|
|
||||||
EnhancedValidationTable,
|
EnhancedValidationTable,
|
||||||
filteredData,
|
filteredData,
|
||||||
fields,
|
fields,
|
||||||
rowSelection,
|
rowSelection,
|
||||||
setRowSelection,
|
handleRowSelectionChange,
|
||||||
updateRow,
|
handleUpdateRow,
|
||||||
validationErrors,
|
validationErrors,
|
||||||
isRowValidatingUpc,
|
isRowValidatingUpc,
|
||||||
validatingUpcRows,
|
validatingUpcRows,
|
||||||
@@ -875,9 +914,57 @@ const ValidationContainer = <T extends string>({
|
|||||||
applyTemplate,
|
applyTemplate,
|
||||||
getTemplateDisplayText,
|
getTemplateDisplayText,
|
||||||
isLoadingTemplates,
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100vh-10rem)] overflow-hidden">
|
<div className="flex flex-col h-[calc(100vh-10rem)] overflow-hidden">
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
@@ -937,13 +1024,26 @@ const ValidationContainer = <T extends string>({
|
|||||||
{/* Main table section */}
|
{/* Main table section */}
|
||||||
<div className="px-8 pb-6 flex-1 min-h-0">
|
<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="rounded-md border h-full flex flex-col overflow-hidden">
|
||||||
<div className="flex-1 overflow-auto">
|
<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}
|
{renderValidationTable}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Selection Action Bar - only shown when items are selected */}
|
{/* Selection Action Bar - only shown when items are selected */}
|
||||||
{Object.keys(rowSelection).length > 0 && (
|
{Object.keys(rowSelection).length > 0 && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useRef, useEffect, useLayoutEffect, useCallback } from 'react'
|
import React, { useMemo, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
useReactTable,
|
useReactTable,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -49,233 +49,6 @@ interface ValidationTableProps<T extends string> {
|
|||||||
[key: string]: any
|
[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>({
|
const ValidationTable = <T extends string>({
|
||||||
data,
|
data,
|
||||||
fields,
|
fields,
|
||||||
@@ -294,75 +67,22 @@ const ValidationTable = <T extends string>({
|
|||||||
}: ValidationTableProps<T>) => {
|
}: ValidationTableProps<T>) => {
|
||||||
const { translations } = useRsi<T>();
|
const { translations } = useRsi<T>();
|
||||||
|
|
||||||
// Create a global scroll position manager
|
// Memoize the selection column with stable callback
|
||||||
const scrollManager = useRef({
|
const handleSelectAll = useCallback((value: boolean, table: any) => {
|
||||||
windowX: 0,
|
table.toggleAllPageRowsSelected(!!value);
|
||||||
windowY: 0,
|
}, []);
|
||||||
containerLeft: 0,
|
|
||||||
containerTop: 0,
|
|
||||||
isScrolling: false,
|
|
||||||
|
|
||||||
// Save current scroll positions
|
const handleRowSelect = useCallback((value: boolean, row: any) => {
|
||||||
save: function() {
|
row.toggleSelected(!!value);
|
||||||
this.windowX = window.scrollX;
|
}, []);
|
||||||
this.windowY = window.scrollY;
|
|
||||||
if (tableContainerRef.current) {
|
|
||||||
this.containerLeft = tableContainerRef.current.scrollLeft;
|
|
||||||
this.containerTop = tableContainerRef.current.scrollTop;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 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> => ({
|
const selectionColumn = useMemo((): ColumnDef<RowData<T>, any> => ({
|
||||||
id: 'select',
|
id: 'select',
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
<div className="flex h-full items-center justify-center py-2">
|
<div className="flex h-full items-center justify-center py-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={table.getIsAllPageRowsSelected()}
|
checked={table.getIsAllPageRowsSelected()}
|
||||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
onCheckedChange={(value) => handleSelectAll(!!value, table)}
|
||||||
aria-label="Select all"
|
aria-label="Select all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -371,7 +91,7 @@ const ValidationTable = <T extends string>({
|
|||||||
<div className="flex h-[40px] items-center justify-center">
|
<div className="flex h-[40px] items-center justify-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={row.getIsSelected()}
|
checked={row.getIsSelected()}
|
||||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
onCheckedChange={(value) => handleRowSelect(!!value, row)}
|
||||||
aria-label="Select row"
|
aria-label="Select row"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -379,9 +99,14 @@ const ValidationTable = <T extends string>({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
size: 50,
|
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> => ({
|
const templateColumn = useMemo((): ColumnDef<RowData<T>, any> => ({
|
||||||
accessorKey: '__template',
|
accessorKey: '__template',
|
||||||
header: 'Template',
|
header: 'Template',
|
||||||
@@ -391,8 +116,6 @@ const ValidationTable = <T extends string>({
|
|||||||
const defaultBrand = row.original.company || undefined;
|
const defaultBrand = row.original.company || undefined;
|
||||||
const rowIndex = data.findIndex(r => r === row.original);
|
const rowIndex = data.findIndex(r => r === row.original);
|
||||||
|
|
||||||
console.log(`Template cell for row ${row.id}, index ${rowIndex}`);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableCell className="p-1" style={{ width: '200px', minWidth: '200px' }}>
|
<TableCell className="p-1" style={{ width: '200px', minWidth: '200px' }}>
|
||||||
{isLoadingTemplates ? (
|
{isLoadingTemplates ? (
|
||||||
@@ -404,9 +127,7 @@ const ValidationTable = <T extends string>({
|
|||||||
<SearchableTemplateSelect
|
<SearchableTemplateSelect
|
||||||
templates={templates}
|
templates={templates}
|
||||||
value={templateValue || ''}
|
value={templateValue || ''}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => handleTemplateChange(value, rowIndex)}
|
||||||
applyTemplate(value, [rowIndex]);
|
|
||||||
}}
|
|
||||||
getTemplateDisplayText={getTemplateDisplayText}
|
getTemplateDisplayText={getTemplateDisplayText}
|
||||||
defaultBrand={defaultBrand}
|
defaultBrand={defaultBrand}
|
||||||
/>
|
/>
|
||||||
@@ -414,9 +135,19 @@ const ValidationTable = <T extends string>({
|
|||||||
</TableCell>
|
</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 => {
|
const fieldColumns = useMemo(() => fields.map((field): ColumnDef<RowData<T>, any> | null => {
|
||||||
if (field.disabled) return null;
|
if (field.disabled) return null;
|
||||||
|
|
||||||
@@ -433,27 +164,24 @@ const ValidationTable = <T extends string>({
|
|||||||
accessorKey: String(field.key),
|
accessorKey: String(field.key),
|
||||||
header: field.label || String(field.key),
|
header: field.label || String(field.key),
|
||||||
size: fieldWidth,
|
size: fieldWidth,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const cellUpdateRow = (rowIndex: number, key: string, value: any) => {
|
<ValidationCell
|
||||||
updateRow(rowIndex, key as T, value);
|
field={field}
|
||||||
};
|
value={row.original[field.key]}
|
||||||
|
onChange={(value) => handleFieldUpdate(row.index, field.key, value)}
|
||||||
return (
|
errors={validationErrors.get(row.index)?.[String(field.key)] || []}
|
||||||
<MemoizedCell
|
isValidating={validatingCells.has(`${row.index}-${field.key}`)}
|
||||||
field={field as MutableField<T>}
|
fieldKey={String(field.key)}
|
||||||
value={row.original[field.key as keyof typeof row.original]}
|
options={(field.fieldType as any).options || []}
|
||||||
rowIndex={row.index}
|
itemNumber={itemNumbers.get(row.index)}
|
||||||
updateRow={cellUpdateRow}
|
|
||||||
validationErrors={validationErrors}
|
|
||||||
validatingCells={validatingCells}
|
|
||||||
itemNumbers={itemNumbers}
|
|
||||||
width={fieldWidth}
|
width={fieldWidth}
|
||||||
copyDown={(rowIndex, key) => copyDown(rowIndex, key as T)}
|
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
|
// Combine columns
|
||||||
const columns = useMemo(() => [selectionColumn, templateColumn, ...fieldColumns], [selectionColumn, templateColumn, fieldColumns]);
|
const columns = useMemo(() => [selectionColumn, templateColumn, ...fieldColumns], [selectionColumn, templateColumn, fieldColumns]);
|
||||||
@@ -466,24 +194,16 @@ const ValidationTable = <T extends string>({
|
|||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getRowId: (row) => {
|
getRowId: (row) => {
|
||||||
// Prefer __index if available (likely a UUID)
|
|
||||||
if (row.__index) return row.__index;
|
if (row.__index) return row.__index;
|
||||||
|
|
||||||
// Fall back to position in array
|
|
||||||
const index = data.indexOf(row);
|
const index = data.indexOf(row);
|
||||||
return String(index);
|
return String(index);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log selection changes for debugging
|
// Calculate total table width for stable horizontal scrolling
|
||||||
useEffect(() => {
|
const totalWidth = useMemo(() => {
|
||||||
const selectedCount = Object.values(rowSelection).filter(v => v === true).length;
|
return columns.reduce((total, col) => total + (col.size || 0), 0);
|
||||||
const selectedIds = Object.entries(rowSelection)
|
}, [columns]);
|
||||||
.filter(([_, selected]) => selected === true)
|
|
||||||
.map(([id, _]) => id);
|
|
||||||
|
|
||||||
console.log(`Row selection updated: ${selectedCount} rows selected, IDs:`, selectedIds);
|
|
||||||
}, [rowSelection]);
|
|
||||||
|
|
||||||
// Don't render if no data
|
// Don't render if no data
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
@@ -499,29 +219,23 @@ const ValidationTable = <T extends string>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={tableContainerRef} className="overflow-auto max-h-[calc(100vh-300px)]">
|
<Table style={{ width: `${totalWidth}px`, tableLayout: 'fixed' }}>
|
||||||
<Table>
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{table.getFlatHeaders().map((header) => (
|
{table.getFlatHeaders().map((header) => (
|
||||||
<TableHead
|
<TableHead
|
||||||
key={header.id}
|
key={header.id}
|
||||||
style={{
|
style={{
|
||||||
width: `${header.getSize()}px`,
|
width: `${header.getSize()}px`,
|
||||||
minWidth: `${header.getSize()}px`
|
minWidth: `${header.getSize()}px`,
|
||||||
|
maxWidth: `${header.getSize()}px`,
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
backgroundColor: 'inherit',
|
||||||
|
zIndex: 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{header.id === 'select' ? (
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
<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>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -530,38 +244,68 @@ const ValidationTable = <T extends string>({
|
|||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-muted/50",
|
"hover:bg-muted/50",
|
||||||
row.getIsSelected() ? "bg-muted/50" : ""
|
row.getIsSelected() ? "bg-muted/50" : "",
|
||||||
|
validationErrors.get(data.indexOf(row.original)) ? "bg-red-50/40" : ""
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<React.Fragment key={cell.id}>
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
style={{
|
||||||
|
width: `${cell.column.getSize()}px`,
|
||||||
|
minWidth: `${cell.column.getSize()}px`,
|
||||||
|
maxWidth: `${cell.column.getSize()}px`
|
||||||
|
}}
|
||||||
|
>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</React.Fragment>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default React.memo(ValidationTable, (prev, next) => {
|
// Optimize memo comparison
|
||||||
// Deep compare data
|
const areEqual = (prev: ValidationTableProps<any>, next: ValidationTableProps<any>) => {
|
||||||
if (JSON.stringify(prev.data) !== JSON.stringify(next.data)) return false;
|
// 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
|
// Check data length and content
|
||||||
if (JSON.stringify(Array.from(prev.validationErrors.entries())) !==
|
if (prev.data.length !== next.data.length) return false;
|
||||||
JSON.stringify(Array.from(next.validationErrors.entries()))) return false;
|
|
||||||
|
|
||||||
// Compare filters
|
// Check row selection changes
|
||||||
if (JSON.stringify(prev.filters) !== JSON.stringify(next.filters)) return false;
|
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
|
// Check validation errors
|
||||||
if (JSON.stringify(prev.rowSelection) !== JSON.stringify(next.rowSelection)) return false;
|
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;
|
return true;
|
||||||
});
|
};
|
||||||
|
|
||||||
|
export default React.memo(ValidationTable, areEqual);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useCallback } from 'react'
|
import React, { useState, useCallback, useDeferredValue, useTransition } from 'react'
|
||||||
import { Field } from '../../../../types'
|
import { Field } from '../../../../types'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
@@ -13,6 +13,7 @@ interface InputCellProps<T extends string> {
|
|||||||
hasErrors?: boolean
|
hasErrors?: boolean
|
||||||
isMultiline?: boolean
|
isMultiline?: boolean
|
||||||
isPrice?: boolean
|
isPrice?: boolean
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const InputCell = <T extends string>({
|
const InputCell = <T extends string>({
|
||||||
@@ -22,12 +23,15 @@ const InputCell = <T extends string>({
|
|||||||
onEndEdit,
|
onEndEdit,
|
||||||
hasErrors,
|
hasErrors,
|
||||||
isMultiline = false,
|
isMultiline = false,
|
||||||
isPrice = false
|
isPrice = false,
|
||||||
|
disabled = false
|
||||||
}: InputCellProps<T>) => {
|
}: InputCellProps<T>) => {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [editValue, setEditValue] = useState('')
|
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(() => {
|
const handleFocus = useCallback(() => {
|
||||||
setIsEditing(true)
|
setIsEditing(true)
|
||||||
|
|
||||||
@@ -43,16 +47,17 @@ const InputCell = <T extends string>({
|
|||||||
onStartEdit?.()
|
onStartEdit?.()
|
||||||
}, [value, onStartEdit, isPrice])
|
}, [value, onStartEdit, isPrice])
|
||||||
|
|
||||||
// Handle blur event
|
// Handle blur event - use transition for non-critical updates
|
||||||
const handleBlur = useCallback(() => {
|
const handleBlur = useCallback(() => {
|
||||||
|
startTransition(() => {
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
|
|
||||||
// Format the value for storage (remove formatting like $ for price)
|
// Format the value for storage (remove formatting like $ for price)
|
||||||
let processedValue = editValue
|
let processedValue = deferredEditValue
|
||||||
|
|
||||||
if (isPrice) {
|
if (isPrice) {
|
||||||
// Remove any non-numeric characters except decimal point
|
// Remove any non-numeric characters except decimal point
|
||||||
processedValue = editValue.replace(/[^\d.]/g, '')
|
processedValue = deferredEditValue.replace(/[^\d.]/g, '')
|
||||||
|
|
||||||
// Parse as float and format to 2 decimal places to ensure valid number
|
// Parse as float and format to 2 decimal places to ensure valid number
|
||||||
const numValue = parseFloat(processedValue)
|
const numValue = parseFloat(processedValue)
|
||||||
@@ -63,43 +68,38 @@ const InputCell = <T extends string>({
|
|||||||
|
|
||||||
onChange(processedValue)
|
onChange(processedValue)
|
||||||
onEndEdit?.()
|
onEndEdit?.()
|
||||||
}, [editValue, onChange, onEndEdit, isPrice])
|
})
|
||||||
|
}, [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>) => {
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
let newValue = e.target.value
|
const newValue = isPrice ? e.target.value.replace(/[$,]/g, '') : 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditValue(newValue)
|
setEditValue(newValue)
|
||||||
}, [isPrice])
|
}, [isPrice])
|
||||||
|
|
||||||
// Format price value for display
|
// Format price value for display - memoized and deferred
|
||||||
const getDisplayValue = useCallback(() => {
|
const displayValue = useDeferredValue(
|
||||||
if (!isPrice || !value) return value
|
isPrice && value ?
|
||||||
|
parseFloat(String(value).replace(/[^\d.]/g, '')).toFixed(2) :
|
||||||
// Extract numeric part
|
value ?? ''
|
||||||
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])
|
|
||||||
|
|
||||||
// Add outline even when not in focus
|
// Add outline even when not in focus
|
||||||
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
|
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
|
|
||||||
|
// If disabled, just render the value without any interactivity
|
||||||
|
if (disabled) {
|
||||||
|
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 (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{isMultiline ? (
|
{isMultiline ? (
|
||||||
@@ -125,7 +125,8 @@ const InputCell = <T extends string>({
|
|||||||
autoFocus
|
autoFocus
|
||||||
className={cn(
|
className={cn(
|
||||||
outlineClass,
|
outlineClass,
|
||||||
hasErrors ? "border-destructive" : ""
|
hasErrors ? "border-destructive" : "",
|
||||||
|
isPending ? "opacity-50" : ""
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -137,7 +138,7 @@ const InputCell = <T extends string>({
|
|||||||
hasErrors ? "border-destructive" : "border-input"
|
hasErrors ? "border-destructive" : "border-input"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isPrice ? getDisplayValue() : (value ?? '')}
|
{displayValue}
|
||||||
</div>
|
</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) => {
|
export default React.memo(InputCell, (prev, next) => {
|
||||||
// Only re-render if these props change
|
if (prev.isEditing !== next.isEditing) return false;
|
||||||
return (
|
if (prev.hasErrors !== next.hasErrors) return false;
|
||||||
prev.value === next.value &&
|
if (prev.isMultiline !== next.isMultiline) return false;
|
||||||
prev.hasErrors === next.hasErrors &&
|
if (prev.isPrice !== next.isPrice) return false;
|
||||||
prev.isMultiline === next.isMultiline &&
|
// Only check value if not editing
|
||||||
prev.isPrice === next.isPrice
|
if (!prev.isEditing && prev.value !== next.value) return false;
|
||||||
)
|
return true;
|
||||||
})
|
});
|
||||||
@@ -26,6 +26,7 @@ interface MultiInputCellProps<T extends string> {
|
|||||||
isMultiline?: boolean
|
isMultiline?: boolean
|
||||||
isPrice?: boolean
|
isPrice?: boolean
|
||||||
options?: readonly FieldOption[]
|
options?: readonly FieldOption[]
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add global CSS to ensure fixed width constraints - use !important to override other styles
|
// Add global CSS to ensure fixed width constraints - use !important to override other styles
|
||||||
@@ -41,7 +42,8 @@ const MultiInputCell = <T extends string>({
|
|||||||
separator = ',',
|
separator = ',',
|
||||||
isMultiline = false,
|
isMultiline = false,
|
||||||
isPrice = false,
|
isPrice = false,
|
||||||
options: providedOptions
|
options: providedOptions,
|
||||||
|
disabled = false
|
||||||
}: MultiInputCellProps<T>) => {
|
}: MultiInputCellProps<T>) => {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
@@ -180,6 +182,27 @@ const MultiInputCell = <T extends string>({
|
|||||||
// Add outline even when not in focus
|
// Add outline even when not in focus
|
||||||
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
|
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
|
|
||||||
|
// If disabled, 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 we have a multi-select field with options, use command UI
|
||||||
if (field.fieldType.type === 'multi-select' && selectOptions.length > 0) {
|
if (field.fieldType.type === 'multi-select' && selectOptions.length > 0) {
|
||||||
// Get width from field if available, or default to a reasonable value
|
// Get width from field if available, or default to a reasonable value
|
||||||
|
|||||||
@@ -2,20 +2,10 @@ import { useState, useRef, useCallback, useMemo } from 'react'
|
|||||||
import { Field } from '../../../../types'
|
import { Field } from '../../../../types'
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||||
Command,
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from '@/components/ui/command'
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from '@/components/ui/popover'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
export type SelectOption = {
|
export type SelectOption = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -24,14 +14,16 @@ export type SelectOption = {
|
|||||||
|
|
||||||
interface SelectCellProps<T extends string> {
|
interface SelectCellProps<T extends string> {
|
||||||
field: Field<T>
|
field: Field<T>
|
||||||
value: string
|
value: any
|
||||||
onChange: (value: string) => void
|
onChange: (value: any) => void
|
||||||
onStartEdit?: () => void
|
onStartEdit?: () => void
|
||||||
onEndEdit?: () => void
|
onEndEdit?: () => void
|
||||||
hasErrors?: boolean
|
hasErrors?: boolean
|
||||||
options?: readonly SelectOption[]
|
options: readonly any[]
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lightweight version of the select cell with minimal dependencies
|
||||||
const SelectCell = <T extends string>({
|
const SelectCell = <T extends string>({
|
||||||
field,
|
field,
|
||||||
value,
|
value,
|
||||||
@@ -39,11 +31,27 @@ const SelectCell = <T extends string>({
|
|||||||
onStartEdit,
|
onStartEdit,
|
||||||
onEndEdit,
|
onEndEdit,
|
||||||
hasErrors,
|
hasErrors,
|
||||||
options
|
options = [],
|
||||||
|
disabled = false
|
||||||
}: SelectCellProps<T>) => {
|
}: SelectCellProps<T>) => {
|
||||||
const [open, setOpen] = useState(false)
|
// State for the open/closed state of the dropdown
|
||||||
// Ref for the command list to enable scrolling
|
const [open, setOpen] = useState(false);
|
||||||
const commandListRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
// 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
|
// Memoize options processing to avoid recalculation on every render
|
||||||
const selectOptions = useMemo(() => {
|
const selectOptions = useMemo(() => {
|
||||||
@@ -62,7 +70,6 @@ const SelectCell = <T extends string>({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if (processedOptions.length === 0) {
|
if (processedOptions.length === 0) {
|
||||||
// Add a default empty option if we have none
|
|
||||||
processedOptions.push({ label: 'No options available', value: '' });
|
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
|
// Memoize display value to avoid recalculation on every render
|
||||||
const displayValue = useMemo(() => {
|
const displayValue = useMemo(() => {
|
||||||
return value ?
|
return internalValue ?
|
||||||
selectOptions.find((option: SelectOption) => String(option.value) === String(value))?.label || String(value) :
|
selectOptions.find((option: SelectOption) => String(option.value) === String(internalValue))?.label || String(internalValue) :
|
||||||
'Select...';
|
'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) => {
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||||
if (commandListRef.current) {
|
if (commandListRef.current) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -84,10 +91,25 @@ const SelectCell = <T extends string>({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Handle selection - UPDATE INTERNAL VALUE FIRST
|
||||||
const handleSelect = useCallback((selectedValue: string) => {
|
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);
|
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();
|
if (onEndEdit) onEndEdit();
|
||||||
|
|
||||||
|
// 5. Call onChange in the next tick to avoid synchronous re-renders
|
||||||
|
setTimeout(() => {
|
||||||
|
onChange(selectedValue);
|
||||||
|
}, 0);
|
||||||
}, [onChange, onEndEdit]);
|
}, [onChange, onEndEdit]);
|
||||||
|
|
||||||
// Memoize the command items to avoid recreating them on every render
|
// Memoize the command items to avoid recreating them on every render
|
||||||
@@ -97,17 +119,41 @@ const SelectCell = <T extends string>({
|
|||||||
key={option.value}
|
key={option.value}
|
||||||
value={option.label}
|
value={option.label}
|
||||||
onSelect={() => handleSelect(option.value)}
|
onSelect={() => handleSelect(option.value)}
|
||||||
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
{String(option.value) === String(value) && (
|
{String(option.value) === String(internalValue) && (
|
||||||
<Check className="ml-auto h-4 w-4" />
|
<Check className="ml-auto h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</CommandItem>
|
</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 (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<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={(isOpen) => {
|
||||||
|
setOpen(isOpen);
|
||||||
|
if (isOpen && onStartEdit) onStartEdit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -116,21 +162,33 @@ const SelectCell = <T extends string>({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between font-normal",
|
"w-full justify-between font-normal",
|
||||||
"border",
|
"border",
|
||||||
!value && "text-muted-foreground",
|
!internalValue && "text-muted-foreground",
|
||||||
|
isProcessing && "text-muted-foreground",
|
||||||
hasErrors ? "border-destructive" : ""
|
hasErrors ? "border-destructive" : ""
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
setOpen(!open)
|
e.preventDefault();
|
||||||
if (onStartEdit) onStartEdit()
|
e.stopPropagation();
|
||||||
|
setOpen(!open);
|
||||||
|
if (!open && onStartEdit) onStartEdit();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<span className={isProcessing ? "opacity-70" : ""}>
|
||||||
{displayValue}
|
{displayValue}
|
||||||
|
</span>
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0" align="start">
|
<PopoverContent
|
||||||
<Command>
|
className="p-0 w-[var(--radix-popover-trigger-width)]"
|
||||||
<CommandInput placeholder="Search..." />
|
align="start"
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search..."
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
<CommandList
|
<CommandList
|
||||||
ref={commandListRef}
|
ref={commandListRef}
|
||||||
onWheel={handleWheel}
|
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
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -78,6 +78,21 @@ declare global {
|
|||||||
// Use a helper to get API URL consistently
|
// Use a helper to get API URL consistently
|
||||||
export const getApiUrl = () => config.apiUrl;
|
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
|
// Main validation state hook
|
||||||
export const useValidationState = <T extends string>({
|
export const useValidationState = <T extends string>({
|
||||||
initialData,
|
initialData,
|
||||||
@@ -165,7 +180,7 @@ export const useValidationState = <T extends string>({
|
|||||||
const [isValidating] = useState(false)
|
const [isValidating] = useState(false)
|
||||||
const [validationErrors, setValidationErrors] = useState<Map<number, Record<string, ErrorType[]>>>(new Map())
|
const [validationErrors, setValidationErrors] = useState<Map<number, Record<string, ErrorType[]>>>(new Map())
|
||||||
const [rowValidationStatus, setRowValidationStatus] = useState<Map<number, 'pending' | 'validating' | 'validated' | 'error'>>(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
|
// Template state
|
||||||
const [templates, setTemplates] = useState<Template[]>([])
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
@@ -195,162 +210,118 @@ export const useValidationState = <T extends string>({
|
|||||||
|
|
||||||
// Add debounce timer ref for item number validation
|
// Add debounce timer ref for item number validation
|
||||||
|
|
||||||
// Function to validate uniqueness of item numbers across the entire table
|
// Add batch update state
|
||||||
const validateItemNumberUniqueness = useCallback(() => {
|
const pendingUpdatesRef = useRef<{
|
||||||
// Create a map to track item numbers and their occurrences
|
errors: Map<number, Record<string, ErrorType[]>>,
|
||||||
const itemNumberMap = new Map<string, number[]>();
|
statuses: Map<number, 'pending' | 'validating' | 'validated' | 'error'>,
|
||||||
|
data: Array<RowData<T>>
|
||||||
// First pass: collect all item numbers and their row indices
|
}>({
|
||||||
data.forEach((row, rowIndex) => {
|
errors: new Map(),
|
||||||
const itemNumber = row.item_number;
|
statuses: new Map(),
|
||||||
if (itemNumber) {
|
data: []
|
||||||
if (!itemNumberMap.has(itemNumber)) {
|
|
||||||
itemNumberMap.set(itemNumber, [rowIndex]);
|
|
||||||
} else {
|
|
||||||
itemNumberMap.get(itemNumber)?.push(rowIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only process duplicates - skip if no duplicates found
|
// Optimized batch update function
|
||||||
const duplicates = Array.from(itemNumberMap.entries())
|
const flushPendingUpdates = useCallback(() => {
|
||||||
.filter(([_, indices]) => indices.length > 1);
|
const updates = pendingUpdatesRef.current;
|
||||||
|
|
||||||
if (duplicates.length === 0) return;
|
if (updates.errors.size > 0) {
|
||||||
|
|
||||||
// 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]) => {
|
|
||||||
rowIndices.forEach(rowIndex => {
|
|
||||||
// Collect errors for batch update
|
|
||||||
const rowErrors = validationErrors.get(rowIndex) || {};
|
|
||||||
errorsToUpdate.set(rowIndex, {
|
|
||||||
...rowErrors,
|
|
||||||
item_number: [{
|
|
||||||
message: 'Duplicate item number',
|
|
||||||
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 => {
|
setValidationErrors(prev => {
|
||||||
const updated = new Map(prev);
|
const newErrors = new Map(prev);
|
||||||
errorsToUpdate.forEach((errors, rowIndex) => {
|
updates.errors.forEach((errors, rowIndex) => {
|
||||||
updated.set(rowIndex, errors);
|
if (Object.keys(errors).length === 0) {
|
||||||
});
|
newErrors.delete(rowIndex);
|
||||||
return updated;
|
} else {
|
||||||
});
|
newErrors.set(rowIndex, errors);
|
||||||
|
|
||||||
// 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 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;
|
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);
|
||||||
}
|
}
|
||||||
}, [data, validationErrors]);
|
if (updates.status) {
|
||||||
|
pendingUpdatesRef.current.statuses.set(rowIndex, updates.status);
|
||||||
|
}
|
||||||
|
if (updates.data) {
|
||||||
|
pendingUpdatesRef.current.data[rowIndex] = updates.data;
|
||||||
|
}
|
||||||
|
debouncedFlushUpdates();
|
||||||
|
}, [debouncedFlushUpdates]);
|
||||||
|
|
||||||
// Effect to update data when UPC validation results change
|
// Update validateUniqueItemNumbers to use batch updates
|
||||||
useEffect(() => {
|
const validateUniqueItemNumbers = useCallback(async () => {
|
||||||
if (upcValidationResults.size === 0) return;
|
const duplicates = new Map<string, number[]>();
|
||||||
|
const itemNumberMap = new Map<string, number>();
|
||||||
|
|
||||||
// Save scroll position
|
data.forEach((row, index) => {
|
||||||
const scrollPosition = {
|
const itemNumber = row.item_number?.toString();
|
||||||
left: window.scrollX,
|
if (itemNumber) {
|
||||||
top: window.scrollY
|
if (itemNumberMap.has(itemNumber)) {
|
||||||
};
|
const existingIndex = itemNumberMap.get(itemNumber)!;
|
||||||
|
if (!duplicates.has(itemNumber)) {
|
||||||
// Process all updates in a single batch
|
duplicates.set(itemNumber, [existingIndex]);
|
||||||
const updatedData = [...data];
|
}
|
||||||
const updatedErrors = new Map(validationErrors);
|
duplicates.get(itemNumber)!.push(index);
|
||||||
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 {
|
} else {
|
||||||
updatedStatus.set(rowIndex, 'validated');
|
itemNumberMap.set(itemNumber, index);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only update state if there were changes
|
duplicates.forEach((rowIndices, itemNumber) => {
|
||||||
if (hasChanges) {
|
rowIndices.forEach(rowIndex => {
|
||||||
// Apply all updates
|
const errors = {
|
||||||
setData(updatedData);
|
item_number: [{
|
||||||
setValidationErrors(updatedErrors);
|
message: `Duplicate item number: ${itemNumber}`,
|
||||||
setRowValidationStatus(updatedStatus);
|
level: 'error',
|
||||||
|
source: 'validation'
|
||||||
// Validate uniqueness after state updates
|
}]
|
||||||
requestAnimationFrame(() => {
|
};
|
||||||
// Restore scroll position
|
queueUpdate(rowIndex, { errors });
|
||||||
window.scrollTo(scrollPosition.left, scrollPosition.top);
|
|
||||||
|
|
||||||
// Check for duplicate item numbers
|
|
||||||
validateItemNumberUniqueness();
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
}, [upcValidationResults, data, validationErrors, rowValidationStatus, validateItemNumberUniqueness]);
|
|
||||||
|
debouncedFlushUpdates();
|
||||||
|
}, [data, queueUpdate, debouncedFlushUpdates]);
|
||||||
|
|
||||||
// Fetch product by UPC from API - optimized with proper error handling and types
|
// Fetch product by UPC from API - optimized with proper error handling and types
|
||||||
const fetchProductByUpc = useCallback(async (supplier: string, upc: string): Promise<ValidationResult> => {
|
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 }> => {
|
const validateUpc = useCallback(async (rowIndex: number, supplierId: string, upcValue: string): Promise<{ success: boolean, itemNumber?: string }> => {
|
||||||
try {
|
try {
|
||||||
// Skip if either value is missing
|
|
||||||
if (!supplierId || !upcValue) {
|
if (!supplierId || !upcValue) {
|
||||||
return { success: false };
|
return { success: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark this row as being validated
|
|
||||||
setValidatingUpcRows((prev: number[]) => {
|
|
||||||
return [...prev, rowIndex];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const cacheKey = `${supplierId}-${upcValue}`;
|
const cacheKey = `${supplierId}-${upcValue}`;
|
||||||
if (processedUpcMapRef.current.has(cacheKey)) {
|
if (processedUpcMapRef.current.has(cacheKey)) {
|
||||||
const cachedItemNumber = processedUpcMapRef.current.get(cacheKey);
|
const cachedItemNumber = processedUpcMapRef.current.get(cacheKey);
|
||||||
|
|
||||||
if (cachedItemNumber) {
|
if (cachedItemNumber) {
|
||||||
// Update with cached item number
|
|
||||||
setUpcValidationResults(prev => {
|
setUpcValidationResults(prev => {
|
||||||
const newResults = new Map(prev);
|
const newResults = new Map(prev);
|
||||||
newResults.set(rowIndex, { itemNumber: cachedItemNumber });
|
newResults.set(rowIndex, { itemNumber: cachedItemNumber });
|
||||||
return newResults;
|
return newResults;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove from validating state
|
|
||||||
setValidatingUpcRows(prev => prev.filter(idx => idx !== rowIndex));
|
|
||||||
|
|
||||||
return { success: true, itemNumber: cachedItemNumber };
|
return { success: true, itemNumber: cachedItemNumber };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from validating state
|
|
||||||
setValidatingUpcRows(prev => prev.filter(idx => idx !== rowIndex));
|
|
||||||
return { success: false };
|
return { success: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make API call to validate UPC
|
// Add to validation queue
|
||||||
const apiResult = await fetchProductByUpc(supplierId, upcValue);
|
validationQueueRef.current.push({ rowIndex, supplierId, upcValue });
|
||||||
|
setValidatingUpcRows(prev => [...prev, rowIndex]);
|
||||||
|
|
||||||
// Remove from validating state now that call is complete
|
// Trigger batch processing
|
||||||
setValidatingUpcRows(prev => prev.filter(idx => idx !== rowIndex));
|
debouncedProcessBatch();
|
||||||
|
|
||||||
if (apiResult.error) {
|
return { success: true };
|
||||||
// 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 };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error validating UPC for row ${rowIndex}:`, error);
|
console.error('Error in validateUpc:', error);
|
||||||
|
|
||||||
// Remove from validating state on error
|
|
||||||
setValidatingUpcRows((prev: number[]) => prev.filter((idx: number) => idx !== rowIndex));
|
|
||||||
|
|
||||||
return { success: false };
|
return { success: false };
|
||||||
}
|
}
|
||||||
}, [fetchProductByUpc, setValidatingUpcRows, setUpcValidationResults, setValidationErrors, setData]);
|
}, [debouncedProcessBatch]);
|
||||||
|
|
||||||
// Track which cells are currently being validated - allows targeted re-rendering
|
// Track which cells are currently being validated - allows targeted re-rendering
|
||||||
const isValidatingUpc = useCallback((rowIndex: number) => {
|
const isValidatingUpc = useCallback((rowIndex: number) => {
|
||||||
@@ -912,6 +823,7 @@ useEffect(() => {
|
|||||||
// Update all rows below with the same value using the existing updateRow function
|
// Update all rows below with the same value using the existing updateRow function
|
||||||
// This ensures all validation logic runs consistently
|
// This ensures all validation logic runs consistently
|
||||||
for (let i = rowIndex + 1; i < data.length; i++) {
|
for (let i = rowIndex + 1; i < data.length; i++) {
|
||||||
|
// Just use updateRow which will handle validation with proper timing
|
||||||
updateRow(i, key, sourceValue);
|
updateRow(i, key, sourceValue);
|
||||||
}
|
}
|
||||||
}, [data, updateRow]);
|
}, [data, updateRow]);
|
||||||
@@ -1450,7 +1362,7 @@ useEffect(() => {
|
|||||||
initialValidationDoneRef.current = true;
|
initialValidationDoneRef.current = true;
|
||||||
|
|
||||||
// Run item number uniqueness validation after basic validation
|
// Run item number uniqueness validation after basic validation
|
||||||
validateItemNumberUniqueness();
|
validateUniqueItemNumbers();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1460,80 +1372,6 @@ useEffect(() => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Function to perform UPC validations asynchronously
|
// 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
|
// Run basic validations immediately to update UI
|
||||||
runBasicValidation();
|
runBasicValidation();
|
||||||
|
|||||||
Reference in New Issue
Block a user