Fix validation table scroll location saving issues

This commit is contained in:
2025-03-10 00:17:55 -04:00
parent 1c8709f520
commit b69182e2c7
3 changed files with 450 additions and 388 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import React, { useState, useEffect, useRef, useCallback, useMemo, useLayoutEffect } from 'react'
import { useValidationState, Props } from '../hooks/useValidationState'
import ValidationTable from './ValidationTable'
import { Button } from '@/components/ui/button'
@@ -878,6 +878,53 @@ const ValidationContainer = <T extends string>({
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 (
<div className="flex flex-col h-[calc(100vh-10rem)] overflow-hidden">
<div className="flex-1 overflow-hidden">
@@ -937,8 +984,20 @@ const ValidationContainer = <T extends string>({
{/* Main table section */}
<div className="px-8 pb-6 flex-1 min-h-0">
<div className="rounded-md border h-full flex flex-col overflow-hidden">
<div className="flex-1 overflow-auto">
{renderValidationTable}
<div className="flex-1 overflow-hidden">
<div
ref={scrollContainerRef}
className="overflow-auto max-h-[calc(100vh-300px)] w-full"
style={{
willChange: 'transform',
position: 'relative',
WebkitOverflowScrolling: 'touch' // Improve scroll performance on Safari
}}
>
<div className="min-w-max"> {/* Force container to be at least as wide as content */}
{renderValidationTable}
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useRef, useEffect, useLayoutEffect, useCallback } from 'react'
import React, { useMemo } from 'react'
import {
useReactTable,
getCoreRowModel,
@@ -49,233 +49,6 @@ interface ValidationTableProps<T extends string> {
[key: string]: any
}
// Make Field type mutable for internal use
type MutableField<T extends string> = {
-readonly [K in keyof Field<T>]: Field<T>[K] extends readonly (infer U)[] ? U[] : Field<T>[K]
}
interface MemoizedCellProps<T extends string = string> {
field: MutableField<T>
value: any
rowIndex: number
updateRow: (rowIndex: number, key: string, value: any) => void
validationErrors: Map<number, Record<string, ErrorType[]>>
validatingCells: Set<string>
itemNumbers: Map<number, string>
width: number
copyDown: (rowIndex: number, key: string) => void
}
// Memoized cell component that only updates when its specific data changes
const MemoizedCell = React.memo(({
field,
value,
rowIndex,
updateRow,
validationErrors,
validatingCells,
itemNumbers,
width,
copyDown
}: MemoizedCellProps) => {
const rowErrors = validationErrors.get(rowIndex) || {};
const fieldErrors = rowErrors[String(field.key)] || [];
const isValidating = validatingCells.has(`${rowIndex}-${field.key}`);
// Only compute options when needed for select/multi-select fields
const options = useMemo(() => {
if (field.fieldType.type === 'select' || field.fieldType.type === 'multi-select') {
return Array.from((field.fieldType as any).options || []);
}
return [];
}, [field.fieldType]);
// Memoize the onChange handler to prevent unnecessary re-renders
const handleChange = useCallback((newValue: any) => {
updateRow(rowIndex, field.key, newValue);
}, [updateRow, rowIndex, field.key]);
// Memoize the copyDown handler
const handleCopyDown = useCallback(() => {
copyDown(rowIndex, field.key);
}, [copyDown, rowIndex, field.key]);
return (
<ValidationCell
field={field}
value={value}
onChange={handleChange}
errors={fieldErrors}
isValidating={isValidating}
fieldKey={String(field.key)}
options={options}
itemNumber={itemNumbers.get(rowIndex)}
width={width}
rowIndex={rowIndex}
copyDown={handleCopyDown}
/>
);
}, (prev, next) => {
const fieldKey = String(prev.field.key);
// For item_number fields, only update if the item number or validation state changes
if (fieldKey === 'item_number') {
const prevItemNumber = prev.itemNumbers.get(prev.rowIndex);
const nextItemNumber = next.itemNumbers.get(next.rowIndex);
const prevValidating = prev.validatingCells.has(`${prev.rowIndex}-item_number`);
const nextValidating = next.validatingCells.has(`${next.rowIndex}-item_number`);
return (
prevItemNumber === nextItemNumber &&
prevValidating === nextValidating &&
prev.value === next.value
);
}
// For UPC fields, only update if the value or validation state changes
if (fieldKey === 'upc' || fieldKey === 'barcode') {
const prevValidating = prev.validatingCells.has(`${prev.rowIndex}-${fieldKey}`);
const nextValidating = next.validatingCells.has(`${next.rowIndex}-${fieldKey}`);
return (
prev.value === next.value &&
prevValidating === nextValidating
);
}
// For other fields, only update if the value or errors change
const prevErrors = prev.validationErrors.get(prev.rowIndex)?.[fieldKey];
const nextErrors = next.validationErrors.get(next.rowIndex)?.[fieldKey];
// For select/multi-select fields, also check if options changed
if (prev.field.fieldType.type === 'select' || prev.field.fieldType.type === 'multi-select') {
const prevOptions = (prev.field.fieldType as any).options;
const nextOptions = (next.field.fieldType as any).options;
// If options length changed, we need to re-render
if (prevOptions?.length !== nextOptions?.length) {
return false;
}
}
return (
prev.value === next.value &&
JSON.stringify(prevErrors) === JSON.stringify(nextErrors)
);
});
MemoizedCell.displayName = 'MemoizedCell';
interface MemoizedRowProps {
row: RowData<string>;
fields: readonly {
readonly label: string;
readonly key: string;
readonly description?: string;
readonly alternateMatches?: readonly string[];
readonly validations?: readonly any[];
readonly fieldType: any;
readonly example?: string;
readonly width?: number;
readonly disabled?: boolean;
}[];
updateRow: (rowIndex: number, key: string, value: any) => void;
validationErrors: Map<number, Record<string, ErrorType[]>>;
validatingCells: Set<string>;
itemNumbers: Map<number, string>;
options?: { [key: string]: any[] };
rowIndex: number;
isSelected: boolean;
copyDown: (rowIndex: number, key: string) => void;
}
const MemoizedRow = React.memo<MemoizedRowProps>(({
row,
fields,
updateRow,
validationErrors,
validatingCells,
itemNumbers,
options = {},
rowIndex,
isSelected,
copyDown
}) => {
return (
<TableRow
key={row.__index || rowIndex}
data-state={isSelected && "selected"}
className={validationErrors.get(rowIndex) ? "bg-red-50/40" : "hover:bg-muted/50"}
>
{fields.map((field) => {
if (field.disabled) return null;
const fieldWidth = field.width || (
field.fieldType.type === "checkbox" ? 80 :
field.fieldType.type === "select" ? 150 :
field.fieldType.type === "multi-select" ? 200 :
(field.fieldType.type === "input" || field.fieldType.type === "multi-input") &&
(field.fieldType as any).multiline ? 300 :
150
);
const isValidating = validatingCells.has(`${rowIndex}-${field.key}`);
// Memoize the copyDown handler
const handleCopyDown = () => {
copyDown(rowIndex, field.key);
};
return (
<ValidationCell
key={String(field.key)}
field={field as Field<string>}
value={row[field.key]}
onChange={(value) => updateRow(rowIndex, field.key, value)}
errors={validationErrors.get(rowIndex)?.[String(field.key)] || []}
isValidating={isValidating}
fieldKey={String(field.key)}
options={options[String(field.key)] || []}
width={fieldWidth}
rowIndex={rowIndex}
itemNumber={itemNumbers.get(rowIndex)}
copyDown={handleCopyDown}
/>
);
})}
</TableRow>
);
}, (prev, next) => {
// Compare row data
const prevRowStr = JSON.stringify(prev.row);
const nextRowStr = JSON.stringify(next.row);
if (prevRowStr !== nextRowStr) return false;
// Compare validation errors for this row
const prevErrors = prev.validationErrors.get(prev.rowIndex);
const nextErrors = next.validationErrors.get(next.rowIndex);
if (JSON.stringify(prevErrors) !== JSON.stringify(nextErrors)) return false;
// Compare validation state for this row's cells
const prevValidatingCells = Array.from(prev.validatingCells)
.filter(key => key.startsWith(`${prev.rowIndex}-`));
const nextValidatingCells = Array.from(next.validatingCells)
.filter(key => key.startsWith(`${next.rowIndex}-`));
if (JSON.stringify(prevValidatingCells) !== JSON.stringify(nextValidatingCells)) return false;
// Compare item numbers for this row
const prevItemNumber = prev.itemNumbers.get(prev.rowIndex);
const nextItemNumber = next.itemNumbers.get(next.rowIndex);
if (prevItemNumber !== nextItemNumber) return false;
// Compare selection state
if (prev.isSelected !== next.isSelected) return false;
return true;
});
MemoizedRow.displayName = 'MemoizedRow';
const ValidationTable = <T extends string>({
data,
fields,
@@ -293,67 +66,6 @@ const ValidationTable = <T extends string>({
copyDown
}: ValidationTableProps<T>) => {
const { translations } = useRsi<T>();
// Create a global scroll position manager
const scrollManager = useRef({
windowX: 0,
windowY: 0,
containerLeft: 0,
containerTop: 0,
isScrolling: false,
// 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
const selectionColumn = useMemo((): ColumnDef<RowData<T>, any> => ({
@@ -391,8 +103,6 @@ const ValidationTable = <T extends string>({
const defaultBrand = row.original.company || undefined;
const rowIndex = data.findIndex(r => r === row.original);
console.log(`Template cell for row ${row.id}, index ${rowIndex}`);
return (
<TableCell className="p-1" style={{ width: '200px', minWidth: '200px' }}>
{isLoadingTemplates ? (
@@ -433,25 +143,21 @@ const ValidationTable = <T extends string>({
accessorKey: String(field.key),
header: field.label || String(field.key),
size: fieldWidth,
cell: ({ row }) => {
const cellUpdateRow = (rowIndex: number, key: string, value: any) => {
updateRow(rowIndex, key as T, value);
};
return (
<MemoizedCell
field={field as MutableField<T>}
value={row.original[field.key as keyof typeof row.original]}
rowIndex={row.index}
updateRow={cellUpdateRow}
validationErrors={validationErrors}
validatingCells={validatingCells}
itemNumbers={itemNumbers}
width={fieldWidth}
copyDown={(rowIndex, key) => copyDown(rowIndex, key as T)}
/>
);
}
cell: ({ row }) => (
<ValidationCell
field={field}
value={row.original[field.key]}
onChange={(value) => updateRow(row.index, field.key, value)}
errors={validationErrors.get(row.index)?.[String(field.key)] || []}
isValidating={validatingCells.has(`${row.index}-${field.key}`)}
fieldKey={String(field.key)}
options={(field.fieldType as any).options || []}
itemNumber={itemNumbers.get(row.index)}
width={fieldWidth}
rowIndex={row.index}
copyDown={() => copyDown(row.index, field.key)}
/>
)
};
}).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,
onRowSelectionChange: setRowSelection,
getRowId: (row) => {
// Prefer __index if available (likely a UUID)
if (row.__index) return row.__index;
// Fall back to position in array
const index = data.indexOf(row);
return String(index);
}
});
// Log selection changes for debugging
useEffect(() => {
const selectedCount = Object.values(rowSelection).filter(v => v === true).length;
const selectedIds = Object.entries(rowSelection)
.filter(([_, selected]) => selected === true)
.map(([id, _]) => id);
console.log(`Row selection updated: ${selectedCount} rows selected, IDs:`, selectedIds);
}, [rowSelection]);
// Calculate total table width for stable horizontal scrolling
const totalWidth = useMemo(() => {
return columns.reduce((total, col) => total + (col.size || 0), 0);
}, [columns]);
// Don't render if no data
if (data.length === 0) {
@@ -499,69 +197,63 @@ const ValidationTable = <T extends string>({
}
return (
<div ref={tableContainerRef} className="overflow-auto max-h-[calc(100vh-300px)]">
<Table>
<TableHeader>
<TableRow>
{table.getFlatHeaders().map((header) => (
<TableHead
key={header.id}
style={{
width: `${header.getSize()}px`,
minWidth: `${header.getSize()}px`
<Table style={{ width: `${totalWidth}px`, tableLayout: 'fixed' }}>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
{table.getFlatHeaders().map((header) => (
<TableHead
key={header.id}
style={{
width: `${header.getSize()}px`,
minWidth: `${header.getSize()}px`,
maxWidth: `${header.getSize()}px`,
position: 'sticky',
top: 0,
backgroundColor: 'inherit',
zIndex: 1
}}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className={cn(
"hover:bg-muted/50",
row.getIsSelected() ? "bg-muted/50" : "",
validationErrors.get(data.indexOf(row.original)) ? "bg-red-50/40" : ""
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: `${cell.column.getSize()}px`,
minWidth: `${cell.column.getSize()}px`,
maxWidth: `${cell.column.getSize()}px`
}}
>
{header.id === 'select' ? (
<div className="flex h-full items-center justify-center py-2">
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
) : (
flexRender(header.column.columnDef.header, header.getContext())
)}
</TableHead>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={cn(
"hover:bg-muted/50",
row.getIsSelected() ? "bg-muted/50" : ""
)}
>
{row.getVisibleCells().map((cell) => (
<React.Fragment key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</React.Fragment>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
))}
</TableBody>
</Table>
);
};
export default React.memo(ValidationTable, (prev, next) => {
// Deep compare data
if (JSON.stringify(prev.data) !== JSON.stringify(next.data)) return false;
// Compare validation errors
if (JSON.stringify(Array.from(prev.validationErrors.entries())) !==
JSON.stringify(Array.from(next.validationErrors.entries()))) 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;
// Add more specific checks to prevent unnecessary re-renders
if (prev.data.length !== next.data.length) return false;
if (prev.validationErrors.size !== next.validationErrors.size) return false;
if (prev.filters?.showErrorsOnly !== next.filters?.showErrorsOnly) return false;
if (prev.validatingCells.size !== next.validatingCells.size) return false;
if (prev.itemNumbers.size !== next.itemNumbers.size) return false;
if (prev.templates.length !== next.templates.length) return false;
return true;
});