Fix validation table scroll location saving issues
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user