Fix validation table scroll location saving issues
This commit is contained in:
@@ -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.
|
||||||
@@ -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'
|
||||||
@@ -878,6 +878,53 @@ const ValidationContainer = <T extends string>({
|
|||||||
copyDown
|
copyDown
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Add scroll container ref at the container level
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const lastScrollPosition = useRef({ left: 0, top: 0 });
|
||||||
|
const isScrolling = useRef(false);
|
||||||
|
|
||||||
|
// Save scroll position when scrolling
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
if (!isScrolling.current && scrollContainerRef.current) {
|
||||||
|
isScrolling.current = true;
|
||||||
|
lastScrollPosition.current = {
|
||||||
|
left: scrollContainerRef.current.scrollLeft,
|
||||||
|
top: scrollContainerRef.current.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 +984,25 @@ 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
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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 } 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,67 +67,6 @@ const ValidationTable = <T extends string>({
|
|||||||
}: ValidationTableProps<T>) => {
|
}: ValidationTableProps<T>) => {
|
||||||
const { translations } = useRsi<T>();
|
const { translations } = useRsi<T>();
|
||||||
|
|
||||||
// Create a global scroll position manager
|
|
||||||
const scrollManager = useRef({
|
|
||||||
windowX: 0,
|
|
||||||
windowY: 0,
|
|
||||||
containerLeft: 0,
|
|
||||||
containerTop: 0,
|
|
||||||
isScrolling: false,
|
|
||||||
|
|
||||||
// Save current scroll positions
|
|
||||||
save: function() {
|
|
||||||
this.windowX = window.scrollX;
|
|
||||||
this.windowY = window.scrollY;
|
|
||||||
if (tableContainerRef.current) {
|
|
||||||
this.containerLeft = tableContainerRef.current.scrollLeft;
|
|
||||||
this.containerTop = tableContainerRef.current.scrollTop;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 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
|
// Memoize the selection column
|
||||||
const selectionColumn = useMemo((): ColumnDef<RowData<T>, any> => ({
|
const selectionColumn = useMemo((): ColumnDef<RowData<T>, any> => ({
|
||||||
id: 'select',
|
id: 'select',
|
||||||
@@ -391,8 +103,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 ? (
|
||||||
@@ -433,25 +143,21 @@ 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) => updateRow(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={() => copyDown(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, updateRow, copyDown]);
|
||||||
|
|
||||||
@@ -466,24 +172,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 +197,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 +222,38 @@ 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) => {
|
export default React.memo(ValidationTable, (prev, next) => {
|
||||||
// Deep compare data
|
// Add more specific checks to prevent unnecessary re-renders
|
||||||
if (JSON.stringify(prev.data) !== JSON.stringify(next.data)) return false;
|
if (prev.data.length !== next.data.length) return false;
|
||||||
|
if (prev.validationErrors.size !== next.validationErrors.size) return false;
|
||||||
// Compare validation errors
|
if (prev.filters?.showErrorsOnly !== next.filters?.showErrorsOnly) return false;
|
||||||
if (JSON.stringify(Array.from(prev.validationErrors.entries())) !==
|
if (prev.validatingCells.size !== next.validatingCells.size) return false;
|
||||||
JSON.stringify(Array.from(next.validationErrors.entries()))) return false;
|
if (prev.itemNumbers.size !== next.itemNumbers.size) return false;
|
||||||
|
if (prev.templates.length !== next.templates.length) return false;
|
||||||
// Compare filters
|
|
||||||
if (JSON.stringify(prev.filters) !== JSON.stringify(next.filters)) return false;
|
|
||||||
|
|
||||||
// Compare row selection
|
|
||||||
if (JSON.stringify(prev.rowSelection) !== JSON.stringify(next.rowSelection)) return false;
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user