Attempted improvements to validation to make the validation step table more responsive
This commit is contained in:
@@ -11,7 +11,6 @@ import InputCell from './cells/InputCell'
|
||||
import SelectCell from './cells/SelectCell'
|
||||
import MultiSelectCell from './cells/MultiSelectCell'
|
||||
import { TableCell } from '@/components/ui/table'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
// Context for copy down selection mode
|
||||
export const CopyDownContext = React.createContext<{
|
||||
@@ -80,7 +79,8 @@ const BaseCellContent = React.memo(({
|
||||
className = '',
|
||||
fieldKey = '',
|
||||
onStartEdit,
|
||||
onEndEdit
|
||||
onEndEdit,
|
||||
isValidating
|
||||
}: {
|
||||
field: Field<string>;
|
||||
value: any;
|
||||
@@ -91,6 +91,7 @@ const BaseCellContent = React.memo(({
|
||||
fieldKey?: string;
|
||||
onStartEdit?: () => void;
|
||||
onEndEdit?: () => void;
|
||||
isValidating?: boolean;
|
||||
}) => {
|
||||
// Get field type information
|
||||
const fieldType = fieldKey === 'line' || fieldKey === 'subline'
|
||||
@@ -123,6 +124,7 @@ const BaseCellContent = React.memo(({
|
||||
hasErrors={hasErrors}
|
||||
className={className}
|
||||
disabled={field.disabled}
|
||||
isValidating={isValidating}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -139,6 +141,7 @@ const BaseCellContent = React.memo(({
|
||||
hasErrors={hasErrors}
|
||||
className={className}
|
||||
disabled={field.disabled}
|
||||
isValidating={isValidating}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -155,6 +158,7 @@ const BaseCellContent = React.memo(({
|
||||
hasErrors={hasErrors}
|
||||
className={className}
|
||||
disabled={field.disabled}
|
||||
isValidating={isValidating}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -170,6 +174,7 @@ const BaseCellContent = React.memo(({
|
||||
isMultiline={isMultiline}
|
||||
isPrice={isPrice}
|
||||
disabled={field.disabled}
|
||||
isValidating={isValidating}
|
||||
/>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
@@ -466,13 +471,8 @@ const ValidationCell = React.memo(({
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<div className={`flex items-center justify-center gap-2 border ${hasError || isRequiredButEmpty ? 'border-red-500' : 'border-input'} rounded-md px-2 py-2`}>
|
||||
<Skeleton className="w-full h-4" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}
|
||||
className={`relative truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}
|
||||
style={{
|
||||
backgroundColor: isSourceCell ? '#dbeafe' :
|
||||
isSelectedTarget ? '#bfdbfe' :
|
||||
@@ -492,10 +492,13 @@ const ValidationCell = React.memo(({
|
||||
fieldKey={fieldKey}
|
||||
onStartEdit={handleStartEdit}
|
||||
onEndEdit={handleEndEdit}
|
||||
isValidating={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<span className="pointer-events-none absolute right-2 top-2 h-2 w-2 rounded-full bg-muted-foreground animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useCallback, useState } from 'react'
|
||||
import React, { useMemo, useCallback, useState, useEffect, useRef } from 'react'
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
RowSelectionState,
|
||||
ColumnDef
|
||||
} from '@tanstack/react-table'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { Fields, Field } from '../../../types'
|
||||
import { RowData, Template } from '../hooks/validationTypes'
|
||||
import ValidationCell, { CopyDownContext } from './ValidationCell'
|
||||
@@ -193,6 +194,14 @@ const ValidationTable = <T extends string>({
|
||||
upcValidationResults
|
||||
}: ValidationTableProps<T>) => {
|
||||
const { translations } = useRsi<T>();
|
||||
const tableRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const getScrollElement = useCallback(() => tableRootRef.current?.parentElement ?? null, []);
|
||||
const [, forceRerender] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableRootRef.current) return;
|
||||
forceRerender((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
// Add state for copy down selection mode
|
||||
const [isInCopyDownMode, setIsInCopyDownMode] = useState(false);
|
||||
@@ -393,6 +402,9 @@ const ValidationTable = <T extends string>({
|
||||
options = rowSublines[rowId];
|
||||
}
|
||||
|
||||
const validatingKey = `${row.index}-${fieldKey}`;
|
||||
const isCellValidating = validatingCells.has(validatingKey);
|
||||
|
||||
// Get the current cell value first
|
||||
const currentValue = fieldKey === 'item_number' && row.original[field.key]
|
||||
? row.original[field.key]
|
||||
@@ -471,7 +483,7 @@ const ValidationTable = <T extends string>({
|
||||
value={currentValue}
|
||||
onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)}
|
||||
errors={cellErrors}
|
||||
isValidating={isLoading}
|
||||
isValidating={isLoading || isCellValidating}
|
||||
fieldKey={fieldKey}
|
||||
options={options}
|
||||
itemNumber={itemNumber}
|
||||
@@ -505,6 +517,47 @@ const ValidationTable = <T extends string>({
|
||||
getRowId: useCallback((_row: RowData<T>, index: number) => String(index), []),
|
||||
});
|
||||
|
||||
const rowModel = table.getRowModel();
|
||||
const rows = rowModel.rows;
|
||||
const visibleColumnCount = table.getVisibleFlatColumns().length;
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement,
|
||||
estimateSize: () => 66,
|
||||
overscan: 8,
|
||||
measureElement:
|
||||
typeof window !== 'undefined'
|
||||
? (el: Element | null) => el?.getBoundingClientRect().height || 0
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const scrollElement = getScrollElement();
|
||||
|
||||
const virtualRows = scrollElement
|
||||
? rowVirtualizer.getVirtualItems()
|
||||
: rows.map((_, index) => ({
|
||||
index,
|
||||
key: `row-fallback-${index}`,
|
||||
start: 0,
|
||||
end: 0,
|
||||
size: 0,
|
||||
}));
|
||||
const paddingTop = scrollElement && virtualRows.length > 0 ? virtualRows[0].start : 0;
|
||||
const paddingBottom =
|
||||
scrollElement && virtualRows.length > 0
|
||||
? rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end
|
||||
: 0;
|
||||
|
||||
const measureVirtualRow = useCallback(
|
||||
(node: HTMLTableRowElement | null) => {
|
||||
const scrollEl = getScrollElement();
|
||||
if (!scrollEl || !node) return;
|
||||
rowVirtualizer.measureElement(node);
|
||||
},
|
||||
[getScrollElement, rowVirtualizer]
|
||||
);
|
||||
|
||||
// Calculate total table width for stable horizontal scrolling
|
||||
const totalWidth = useMemo(() => {
|
||||
return columns.reduce((total, col) => total + (col.size || 0), 0);
|
||||
@@ -525,7 +578,7 @@ const ValidationTable = <T extends string>({
|
||||
|
||||
return (
|
||||
<CopyDownContext.Provider value={copyDownContextValue}>
|
||||
<div className="min-w-max relative">
|
||||
<div ref={tableRootRef} className="min-w-max relative">
|
||||
{/* Add global styles for copy down mode */}
|
||||
{isInCopyDownMode && (
|
||||
<style>
|
||||
@@ -630,7 +683,17 @@ const ValidationTable = <T extends string>({
|
||||
transform: 'translateZ(0)' // Force GPU acceleration
|
||||
}}>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
{paddingTop > 0 && (
|
||||
<TableRow key="virtual-padding-top">
|
||||
<TableCell
|
||||
colSpan={visibleColumnCount}
|
||||
style={{ height: `${paddingTop}px`, padding: 0, border: 'none' }}
|
||||
/>
|
||||
</TableRow>
|
||||
)}
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
// Precompute validation error status for this row
|
||||
const hasErrors = validationErrors.has(parseInt(row.id)) &&
|
||||
Object.keys(validationErrors.get(parseInt(row.id)) || {}).length > 0;
|
||||
@@ -659,6 +722,7 @@ const ValidationTable = <T extends string>({
|
||||
isCopyDownTarget ? "cursor-pointer copy-down-target-row" : ""
|
||||
)}
|
||||
style={rowStyle}
|
||||
ref={scrollElement ? measureVirtualRow : undefined}
|
||||
onMouseEnter={() => handleRowMouseEnter(parseInt(row.id))}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: any) => (
|
||||
@@ -669,6 +733,14 @@ const ValidationTable = <T extends string>({
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{paddingBottom > 0 && (
|
||||
<TableRow key="virtual-padding-bottom">
|
||||
<TableCell
|
||||
colSpan={visibleColumnCount}
|
||||
style={{ height: `${paddingBottom}px`, padding: 0, border: 'none' }}
|
||||
/>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react'
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react'
|
||||
import { Field } from '../../../../types'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -15,8 +15,10 @@ interface InputCellProps<T extends string> {
|
||||
isPrice?: boolean
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
isValidating?: boolean
|
||||
}
|
||||
|
||||
|
||||
// (removed unused formatPrice helper)
|
||||
|
||||
const InputCell = <T extends string>({
|
||||
@@ -29,11 +31,12 @@ const InputCell = <T extends string>({
|
||||
isMultiline = false,
|
||||
isPrice = false,
|
||||
disabled = false,
|
||||
className = ''
|
||||
className = '', isValidating: _isValidating = false
|
||||
}: InputCellProps<T>) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [pendingDisplayValue, setPendingDisplayValue] = useState<string | null>(null);
|
||||
|
||||
// Remove optimistic updates and rely on parent state
|
||||
|
||||
@@ -48,6 +51,7 @@ const InputCell = <T extends string>({
|
||||
// Handle focus event
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsEditing(true);
|
||||
setPendingDisplayValue(null);
|
||||
|
||||
if (value !== undefined && value !== null) {
|
||||
if (isPrice) {
|
||||
@@ -68,6 +72,8 @@ const InputCell = <T extends string>({
|
||||
const handleBlur = useCallback(() => {
|
||||
const finalValue = editValue.trim();
|
||||
|
||||
setPendingDisplayValue(finalValue);
|
||||
|
||||
// Save to parent - parent must update immediately for this to work
|
||||
onChange(finalValue);
|
||||
|
||||
@@ -82,22 +88,28 @@ const InputCell = <T extends string>({
|
||||
setEditValue(newValue);
|
||||
}, [isPrice]);
|
||||
|
||||
// Get the display value - use parent value directly
|
||||
const displayValue = useMemo(() => {
|
||||
useEffect(() => {
|
||||
if (pendingDisplayValue === null) return;
|
||||
const currentValue = value ?? '';
|
||||
if (String(currentValue) === pendingDisplayValue) {
|
||||
setPendingDisplayValue(null);
|
||||
}
|
||||
}, [value, pendingDisplayValue]);
|
||||
|
||||
// Handle price formatting for display
|
||||
if (isPrice && currentValue !== '' && currentValue !== undefined && currentValue !== null) {
|
||||
if (typeof currentValue === 'number') {
|
||||
return currentValue.toFixed(2);
|
||||
} else if (typeof currentValue === 'string' && /^-?\d+(\.\d+)?$/.test(currentValue)) {
|
||||
return parseFloat(currentValue).toFixed(2);
|
||||
// Get the display value - prefer pending value when present for immediate feedback
|
||||
const displayValue = useMemo(() => {
|
||||
const rawValue = pendingDisplayValue !== null ? pendingDisplayValue : value ?? '';
|
||||
|
||||
if (isPrice && rawValue !== '' && rawValue !== undefined && rawValue !== null) {
|
||||
if (typeof rawValue === 'number') {
|
||||
return rawValue.toFixed(2);
|
||||
} else if (typeof rawValue === 'string' && /^-?\d+(\.\d+)?$/.test(rawValue)) {
|
||||
return parseFloat(rawValue).toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
// For non-price or invalid price values, return as-is
|
||||
return String(currentValue);
|
||||
}, [isPrice, value]);
|
||||
return String(rawValue);
|
||||
}, [isPrice, value, pendingDisplayValue]);
|
||||
|
||||
// Add outline even when not in focus
|
||||
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0";
|
||||
|
||||
@@ -24,8 +24,10 @@ interface MultiSelectCellProps<T extends string> {
|
||||
options?: readonly FieldOption[]
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
isValidating?: boolean
|
||||
}
|
||||
|
||||
|
||||
// Memoized option item to prevent unnecessary renders for large option lists
|
||||
const OptionItem = React.memo(({
|
||||
option,
|
||||
@@ -158,7 +160,7 @@ const MultiSelectCell = <T extends string>({
|
||||
hasErrors,
|
||||
options: providedOptions,
|
||||
disabled = false,
|
||||
className = ''
|
||||
className = '', isValidating: _isValidating = false
|
||||
}: MultiSelectCellProps<T>) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
@@ -22,8 +22,10 @@ interface SelectCellProps<T extends string> {
|
||||
options: readonly any[]
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
isValidating?: boolean
|
||||
}
|
||||
|
||||
|
||||
// Lightweight version of the select cell with minimal dependencies
|
||||
const SelectCell = <T extends string>({
|
||||
field,
|
||||
@@ -34,7 +36,7 @@ const SelectCell = <T extends string>({
|
||||
hasErrors,
|
||||
options = [],
|
||||
disabled = false,
|
||||
className = ''
|
||||
className = '', isValidating = false
|
||||
}: SelectCellProps<T>) => {
|
||||
// State for the open/closed state of the dropdown
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -47,6 +49,7 @@ const SelectCell = <T extends string>({
|
||||
|
||||
// State to track if the value is being processed/validated
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const showProcessing = isProcessing || isValidating;
|
||||
|
||||
// Add state for hover
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -61,8 +64,10 @@ const SelectCell = <T extends string>({
|
||||
useEffect(() => {
|
||||
setInternalValue(value);
|
||||
// When the value prop changes, it means validation is complete
|
||||
if (!isValidating) {
|
||||
setIsProcessing(false);
|
||||
}, [value]);
|
||||
}
|
||||
}, [value, isValidating]);
|
||||
|
||||
// Memoize options processing to avoid recalculation on every render
|
||||
const selectOptions = useMemo(() => {
|
||||
@@ -144,7 +149,9 @@ const SelectCell = <T extends string>({
|
||||
|
||||
// 6. Clear processing state after a short delay - reduced for responsiveness
|
||||
setTimeout(() => {
|
||||
if (!isValidating) {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, 50);
|
||||
}, [onChange, onEndEdit]);
|
||||
|
||||
@@ -200,7 +207,7 @@ const SelectCell = <T extends string>({
|
||||
"w-full justify-between font-normal",
|
||||
"border",
|
||||
!internalValue && "text-muted-foreground",
|
||||
isProcessing && "text-muted-foreground",
|
||||
showProcessing && "text-muted-foreground",
|
||||
hasErrors ? "border-destructive" : "",
|
||||
className
|
||||
)}
|
||||
@@ -233,7 +240,7 @@ const SelectCell = <T extends string>({
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<span className={isProcessing ? "opacity-70" : ""}>
|
||||
<span className={showProcessing ? "opacity-70" : ""}>
|
||||
{displayValue}
|
||||
</span>
|
||||
<ChevronsUpDown className="mr-1.5 h-4 w-4 shrink-0 opacity-50" />
|
||||
|
||||
@@ -1,21 +1,100 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { RowData } from './validationTypes';
|
||||
import { useCallback, useEffect, useMemo, useRef, startTransition } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { RowData, isEmpty as isValueEmpty } from './validationTypes';
|
||||
import type { Field, Fields } from '../../../types';
|
||||
import { ErrorSources, ErrorType, ValidationError } from '../../../types';
|
||||
import { useUniqueValidation } from './useUniqueValidation';
|
||||
import { isEmpty } from './validationTypes';
|
||||
|
||||
export const useRowOperations = <T extends string>(
|
||||
data: RowData<T>[],
|
||||
fields: Fields<T>,
|
||||
setData: React.Dispatch<React.SetStateAction<RowData<T>[]>>,
|
||||
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>,
|
||||
validateFieldFromHook: (value: any, field: Field<T>) => ValidationError[]
|
||||
validateFieldFromHook: (value: any, field: Field<T>) => ValidationError[],
|
||||
setValidatingCells?: Dispatch<SetStateAction<Set<string>>>
|
||||
) => {
|
||||
// Uniqueness validation utilities
|
||||
const { validateUniqueField } = useUniqueValidation<T>(fields);
|
||||
|
||||
// Determine which field keys are considered uniqueness-constrained
|
||||
const dataRef = useRef(data);
|
||||
useEffect(() => {
|
||||
dataRef.current = data;
|
||||
}, [data]);
|
||||
|
||||
type ValidationTask = { cancel: () => void };
|
||||
const pendingValidationTasksRef = useRef<Map<string, ValidationTask>>(new Map());
|
||||
|
||||
const scheduleIdleTask = useCallback((taskKey: string, runTask: () => void) => {
|
||||
const existingTask = pendingValidationTasksRef.current.get(taskKey);
|
||||
existingTask?.cancel();
|
||||
|
||||
const execute = () => {
|
||||
pendingValidationTasksRef.current.delete(taskKey);
|
||||
runTask();
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const win = window as Window & typeof globalThis & {
|
||||
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
};
|
||||
|
||||
if (win.requestIdleCallback) {
|
||||
const handle = win.requestIdleCallback(() => {
|
||||
execute();
|
||||
}, { timeout: 250 });
|
||||
|
||||
pendingValidationTasksRef.current.set(taskKey, {
|
||||
cancel: () => win.cancelIdleCallback?.(handle),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(execute, 0);
|
||||
pendingValidationTasksRef.current.set(taskKey, {
|
||||
cancel: () => window.clearTimeout(timeoutId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
execute();
|
||||
}, []);
|
||||
|
||||
const updateValidatingCell = useCallback(
|
||||
(rowIndex: number, fieldKey: string, pending: boolean) => {
|
||||
if (!setValidatingCells) return;
|
||||
const cellKey = `${rowIndex}-${fieldKey}`;
|
||||
setValidatingCells((prev: Set<string>) => {
|
||||
const hasKey = prev.has(cellKey);
|
||||
if (pending && hasKey) return prev;
|
||||
if (!pending && !hasKey) return prev;
|
||||
const next = new Set(prev);
|
||||
if (pending) next.add(cellKey);
|
||||
else next.delete(cellKey);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[setValidatingCells]
|
||||
);
|
||||
|
||||
const scheduleFieldValidation = useCallback(
|
||||
(rowIndex: number, fieldKey: string, runValidation: () => void) => {
|
||||
updateValidatingCell(rowIndex, fieldKey, true);
|
||||
try {
|
||||
runValidation();
|
||||
} finally {
|
||||
updateValidatingCell(rowIndex, fieldKey, false);
|
||||
}
|
||||
},
|
||||
[updateValidatingCell]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pendingValidationTasksRef.current.forEach((task) => task.cancel());
|
||||
pendingValidationTasksRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const uniquenessFieldKeys = useMemo(() => {
|
||||
const keys = new Set<string>([
|
||||
'item_number',
|
||||
@@ -25,15 +104,16 @@ export const useRowOperations = <T extends string>(
|
||||
'notions_no',
|
||||
'name'
|
||||
]);
|
||||
fields.forEach((f) => {
|
||||
if (f.validations?.some((v) => v.rule === 'unique')) {
|
||||
keys.add(String(f.key));
|
||||
|
||||
fields.forEach((field) => {
|
||||
if (field.validations?.some((v) => v.rule === 'unique')) {
|
||||
keys.add(String(field.key));
|
||||
}
|
||||
});
|
||||
|
||||
return keys;
|
||||
}, [fields]);
|
||||
|
||||
// Merge per-field uniqueness errors into the validation error map
|
||||
const mergeUniqueErrorsForFields = useCallback(
|
||||
(
|
||||
baseErrors: Map<number, Record<string, ValidationError[]>>,
|
||||
@@ -44,26 +124,19 @@ export const useRowOperations = <T extends string>(
|
||||
|
||||
const newErrors = new Map(baseErrors);
|
||||
|
||||
// For each field, compute duplicates and merge
|
||||
fieldKeysToCheck.forEach((fieldKey) => {
|
||||
if (!uniquenessFieldKeys.has(fieldKey)) return;
|
||||
|
||||
// Compute unique errors for this single field
|
||||
const uniqueMap = validateUniqueField(dataForCalc, fieldKey);
|
||||
|
||||
// Rows that currently have uniqueness errors for this field
|
||||
const rowsWithUniqueErrors = new Set<number>();
|
||||
uniqueMap.forEach((_, rowIdx) => rowsWithUniqueErrors.add(rowIdx));
|
||||
|
||||
// First, apply/overwrite unique errors for rows that have duplicates
|
||||
uniqueMap.forEach((errorsForRow, rowIdx) => {
|
||||
const existing = { ...(newErrors.get(rowIdx) || {}) };
|
||||
|
||||
// Convert InfoWithSource to ValidationError[] for this field
|
||||
const info = errorsForRow[fieldKey];
|
||||
// Only apply uniqueness error when the value is non-empty
|
||||
const currentValue = (dataForCalc[rowIdx] as any)?.[fieldKey];
|
||||
if (info && !isEmpty(currentValue)) {
|
||||
|
||||
if (info && !isValueEmpty(currentValue)) {
|
||||
existing[fieldKey] = [
|
||||
{
|
||||
message: info.message,
|
||||
@@ -78,9 +151,7 @@ export const useRowOperations = <T extends string>(
|
||||
else newErrors.delete(rowIdx);
|
||||
});
|
||||
|
||||
// Then, remove any stale unique errors for this field where duplicates are resolved
|
||||
newErrors.forEach((rowErrs, rowIdx) => {
|
||||
// Skip rows that still have unique errors for this field
|
||||
if (rowsWithUniqueErrors.has(rowIdx)) return;
|
||||
|
||||
if ((rowErrs as any)[fieldKey]) {
|
||||
@@ -99,219 +170,255 @@ export const useRowOperations = <T extends string>(
|
||||
[uniquenessFieldKeys, validateUniqueField]
|
||||
);
|
||||
|
||||
// Helper function to validate a field value
|
||||
const pendingUniqueFieldsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const runUniqueValidation = useCallback(
|
||||
(fieldsToProcess: string[]) => {
|
||||
if (!fieldsToProcess.length) return;
|
||||
setValidationErrors((prev) =>
|
||||
mergeUniqueErrorsForFields(prev, dataRef.current, fieldsToProcess)
|
||||
);
|
||||
},
|
||||
[mergeUniqueErrorsForFields, setValidationErrors]
|
||||
);
|
||||
|
||||
const scheduleUniqueValidation = useCallback(
|
||||
(fieldKeys: string[]) => {
|
||||
if (!fieldKeys.length) return;
|
||||
const uniqueKeys = fieldKeys.filter((key) => uniquenessFieldKeys.has(key));
|
||||
if (!uniqueKeys.length) return;
|
||||
|
||||
if (pendingUniqueFieldsRef.current.size === 0 && uniqueKeys.length <= 2) {
|
||||
const immediateKeys = Array.from(new Set(uniqueKeys));
|
||||
runUniqueValidation(immediateKeys);
|
||||
return;
|
||||
}
|
||||
|
||||
uniqueKeys.forEach((fieldKey) => pendingUniqueFieldsRef.current.add(fieldKey));
|
||||
|
||||
scheduleIdleTask('unique:batch', () => {
|
||||
const fieldsToProcess = Array.from(pendingUniqueFieldsRef.current);
|
||||
pendingUniqueFieldsRef.current.clear();
|
||||
if (!fieldsToProcess.length) return;
|
||||
runUniqueValidation(fieldsToProcess);
|
||||
});
|
||||
},
|
||||
[runUniqueValidation, scheduleIdleTask, uniquenessFieldKeys]
|
||||
);
|
||||
|
||||
const fieldValidationHelper = useCallback(
|
||||
(rowIndex: number, specificField?: string) => {
|
||||
// Skip validation if row doesn't exist
|
||||
if (rowIndex < 0 || rowIndex >= data.length) return;
|
||||
const currentData = dataRef.current;
|
||||
if (rowIndex < 0 || rowIndex >= currentData.length) return;
|
||||
|
||||
// Get the row data
|
||||
const row = data[rowIndex];
|
||||
const row = currentData[rowIndex];
|
||||
|
||||
// If validating a specific field, only check that field
|
||||
if (specificField) {
|
||||
const field = fields.find((f) => String(f.key) === specificField);
|
||||
if (field) {
|
||||
if (!field) return;
|
||||
|
||||
const value = row[specificField as keyof typeof row];
|
||||
|
||||
// Use state setter instead of direct mutation
|
||||
updateValidatingCell(rowIndex, specificField, true);
|
||||
setValidationErrors((prev) => {
|
||||
let newErrors = new Map(prev);
|
||||
const existingErrors = { ...(newErrors.get(rowIndex) || {}) };
|
||||
const existingErrors = prev.get(rowIndex) || {};
|
||||
const newRowErrors = { ...existingErrors };
|
||||
let rowChanged = false;
|
||||
|
||||
// Quick check for required fields - this prevents flashing errors
|
||||
const isRequired = field.validations?.some(
|
||||
(v) => v.rule === "required"
|
||||
);
|
||||
const isEmpty =
|
||||
const isRequired = field.validations?.some((v) => v.rule === 'required');
|
||||
const valueIsEmpty =
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === "" ||
|
||||
value === '' ||
|
||||
(Array.isArray(value) && value.length === 0) ||
|
||||
(typeof value === "object" &&
|
||||
value !== null &&
|
||||
Object.keys(value).length === 0);
|
||||
(typeof value === 'object' && value !== null && Object.keys(value).length === 0);
|
||||
|
||||
if (isRequired && !valueIsEmpty && newRowErrors[specificField]) {
|
||||
const nonRequiredErrors = newRowErrors[specificField].filter((e) => e.type !== ErrorType.Required);
|
||||
|
||||
// For non-empty values, remove required errors immediately
|
||||
if (isRequired && !isEmpty && existingErrors[specificField]) {
|
||||
const nonRequiredErrors = existingErrors[specificField].filter(
|
||||
(e) => e.type !== ErrorType.Required
|
||||
);
|
||||
if (nonRequiredErrors.length === 0) {
|
||||
// If no other errors, remove the field entirely from errors
|
||||
delete existingErrors[specificField];
|
||||
} else {
|
||||
existingErrors[specificField] = nonRequiredErrors;
|
||||
rowChanged = true;
|
||||
delete newRowErrors[specificField];
|
||||
} else if (nonRequiredErrors.length !== newRowErrors[specificField].length) {
|
||||
rowChanged = true;
|
||||
newRowErrors[specificField] = nonRequiredErrors;
|
||||
}
|
||||
}
|
||||
|
||||
// Run full validation for the field
|
||||
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
|
||||
|
||||
// Update validation errors for this field
|
||||
if (errors.length > 0) {
|
||||
existingErrors[specificField] = errors;
|
||||
} else {
|
||||
delete existingErrors[specificField];
|
||||
const existing = newRowErrors[specificField] || [];
|
||||
const sameLength = existing.length === errors.length;
|
||||
const sameContent = sameLength && existing.every((err, idx) => err.message === errors[idx].message && err.type === errors[idx].type);
|
||||
if (!sameContent) {
|
||||
rowChanged = true;
|
||||
newRowErrors[specificField] = errors;
|
||||
}
|
||||
} else if (newRowErrors[specificField]) {
|
||||
rowChanged = true;
|
||||
delete newRowErrors[specificField];
|
||||
}
|
||||
|
||||
// Update validation errors map
|
||||
if (Object.keys(existingErrors).length > 0) {
|
||||
newErrors.set(rowIndex, existingErrors);
|
||||
let resultMap = prev;
|
||||
if (rowChanged) {
|
||||
resultMap = new Map(prev);
|
||||
if (Object.keys(newRowErrors).length > 0) {
|
||||
resultMap.set(rowIndex, newRowErrors);
|
||||
} else {
|
||||
newErrors.delete(rowIndex);
|
||||
resultMap.delete(rowIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// If field is uniqueness-constrained, also re-validate uniqueness for the column
|
||||
if (uniquenessFieldKeys.has(specificField)) {
|
||||
const dataForCalc = data; // latest data
|
||||
newErrors = mergeUniqueErrorsForFields(newErrors, dataForCalc, [specificField]);
|
||||
scheduleUniqueValidation([specificField]);
|
||||
return rowChanged ? resultMap : prev;
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
return rowChanged ? resultMap : prev;
|
||||
});
|
||||
}
|
||||
updateValidatingCell(rowIndex, specificField, false);
|
||||
} else {
|
||||
// Validate all fields in the row
|
||||
setValidationErrors((prev) => {
|
||||
const newErrors = new Map(prev);
|
||||
const rowErrors: Record<string, ValidationError[]> = {};
|
||||
|
||||
fields.forEach((field) => {
|
||||
const fieldKey = String(field.key);
|
||||
const value = row[fieldKey as keyof typeof row];
|
||||
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
|
||||
|
||||
const valueForField = row[fieldKey as keyof typeof row];
|
||||
const errors = validateFieldFromHook(valueForField, field as unknown as Field<T>);
|
||||
if (errors.length > 0) {
|
||||
rowErrors[fieldKey] = errors;
|
||||
}
|
||||
});
|
||||
|
||||
// Update validation errors map
|
||||
if (Object.keys(rowErrors).length > 0) {
|
||||
newErrors.set(rowIndex, rowErrors);
|
||||
} else {
|
||||
newErrors.delete(rowIndex);
|
||||
if (Object.keys(rowErrors).length === 0) {
|
||||
if (!prev.has(rowIndex)) return prev;
|
||||
const result = new Map(prev);
|
||||
result.delete(rowIndex);
|
||||
return result;
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
const existing = prev.get(rowIndex);
|
||||
const sameEntries = existing && Object.keys(rowErrors).length === Object.keys(existing).length && Object.entries(rowErrors).every(([key, val]) => {
|
||||
const existingVal = existing[key];
|
||||
return (
|
||||
existingVal &&
|
||||
existingVal.length === val.length &&
|
||||
existingVal.every((err, idx) => err.message === val[idx].message && err.type === val[idx].type)
|
||||
);
|
||||
});
|
||||
|
||||
if (sameEntries) return prev;
|
||||
|
||||
const result = new Map(prev);
|
||||
result.set(rowIndex, rowErrors);
|
||||
return result;
|
||||
});
|
||||
const uniqueKeys = fields
|
||||
.map((field) => String(field.key))
|
||||
.filter((fieldKey) => uniquenessFieldKeys.has(fieldKey));
|
||||
if (uniqueKeys.length > 0) {
|
||||
scheduleUniqueValidation(uniqueKeys);
|
||||
}
|
||||
}
|
||||
},
|
||||
[data, fields, validateFieldFromHook, setValidationErrors, mergeUniqueErrorsForFields, uniquenessFieldKeys]
|
||||
[fields, scheduleUniqueValidation, setValidationErrors, uniquenessFieldKeys, validateFieldFromHook]
|
||||
);
|
||||
|
||||
// Use validateRow as an alias for fieldValidationHelper for compatibility
|
||||
const validateRow = fieldValidationHelper;
|
||||
|
||||
// Modified updateRow function that properly handles field-specific validation
|
||||
const updateRow = useCallback(
|
||||
(rowIndex: number, key: T, value: any) => {
|
||||
// Process value before updating data
|
||||
let processedValue = value;
|
||||
|
||||
// Strip dollar signs from price fields
|
||||
if (
|
||||
(key === "msrp" || key === "cost_each") &&
|
||||
typeof value === "string"
|
||||
) {
|
||||
processedValue = value.replace(/[$,]/g, "");
|
||||
|
||||
// Also ensure it's a valid number
|
||||
if ((key === 'msrp' || key === 'cost_each') && typeof value === 'string') {
|
||||
processedValue = value.replace(/[$,]/g, '');
|
||||
const numValue = parseFloat(processedValue);
|
||||
if (!isNaN(numValue)) {
|
||||
if (!Number.isNaN(numValue)) {
|
||||
processedValue = numValue.toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Find the row data first
|
||||
const rowData = data[rowIndex];
|
||||
const currentData = dataRef.current;
|
||||
const rowData = currentData[rowIndex];
|
||||
if (!rowData) {
|
||||
console.error(`No row data found for index ${rowIndex}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a copy of the row to avoid mutation
|
||||
const updatedRow = { ...rowData, [key]: processedValue };
|
||||
|
||||
// Update the data immediately - this sets the value
|
||||
setData((prevData) => {
|
||||
const newData = [...prevData];
|
||||
if (rowIndex >= 0 && rowIndex < newData.length) {
|
||||
newData[rowIndex] = updatedRow;
|
||||
const nextData = [...currentData];
|
||||
if (rowIndex >= 0 && rowIndex < nextData.length) {
|
||||
nextData[rowIndex] = updatedRow;
|
||||
}
|
||||
return newData;
|
||||
dataRef.current = nextData;
|
||||
|
||||
startTransition(() => {
|
||||
setData(() => nextData);
|
||||
});
|
||||
|
||||
// Find the field definition
|
||||
const field = fields.find((f) => String(f.key) === key);
|
||||
if (!field) return;
|
||||
|
||||
// CRITICAL FIX: Combine both validation operations into a single state update
|
||||
// to prevent intermediate rendering that causes error icon flashing
|
||||
scheduleFieldValidation(rowIndex, String(key), () => {
|
||||
setValidationErrors((prev) => {
|
||||
// Start with previous errors
|
||||
let newMap = new Map(prev);
|
||||
const existingErrors = newMap.get(rowIndex) || {};
|
||||
const existingErrors = prev.get(rowIndex) || {};
|
||||
const newRowErrors = { ...existingErrors };
|
||||
let rowChanged = false;
|
||||
|
||||
// Check for required field first
|
||||
const isRequired = field.validations?.some(
|
||||
(v) => v.rule === "required"
|
||||
);
|
||||
const isEmpty =
|
||||
processedValue === undefined ||
|
||||
processedValue === null ||
|
||||
processedValue === "" ||
|
||||
(Array.isArray(processedValue) && processedValue.length === 0) ||
|
||||
(typeof processedValue === "object" &&
|
||||
processedValue !== null &&
|
||||
Object.keys(processedValue).length === 0);
|
||||
const latestRow = dataRef.current[rowIndex];
|
||||
const currentValue = latestRow ? (latestRow[String(key) as keyof typeof latestRow] as unknown) : processedValue;
|
||||
|
||||
// For required fields with values, remove required errors
|
||||
if (isRequired && !isEmpty && newRowErrors[key as string]) {
|
||||
const hasRequiredError = newRowErrors[key as string].some(
|
||||
(e) => e.type === ErrorType.Required
|
||||
);
|
||||
|
||||
if (hasRequiredError) {
|
||||
// Remove required errors but keep other types of errors
|
||||
const nonRequiredErrors = newRowErrors[key as string].filter(
|
||||
(e) => e.type !== ErrorType.Required
|
||||
);
|
||||
const isRequired = field.validations?.some((v) => v.rule === 'required');
|
||||
const valueIsEmpty =
|
||||
currentValue === undefined ||
|
||||
currentValue === null ||
|
||||
currentValue === '' ||
|
||||
(Array.isArray(currentValue) && currentValue.length === 0) ||
|
||||
(typeof currentValue === 'object' && currentValue !== null && Object.keys(currentValue).length === 0);
|
||||
|
||||
if (isRequired && !valueIsEmpty && newRowErrors[String(key)]) {
|
||||
const nonRequiredErrors = newRowErrors[String(key)].filter((e) => e.type !== ErrorType.Required);
|
||||
if (nonRequiredErrors.length === 0) {
|
||||
// If no other errors, delete the field's errors entirely
|
||||
delete newRowErrors[key as string];
|
||||
} else {
|
||||
// Otherwise keep non-required errors
|
||||
newRowErrors[key as string] = nonRequiredErrors;
|
||||
if (newRowErrors[String(key)]) {
|
||||
rowChanged = true;
|
||||
delete newRowErrors[String(key)];
|
||||
}
|
||||
} else if (nonRequiredErrors.length !== newRowErrors[String(key)].length) {
|
||||
rowChanged = true;
|
||||
newRowErrors[String(key)] = nonRequiredErrors;
|
||||
}
|
||||
}
|
||||
|
||||
// Now run full validation for the field (except for required which we already handled)
|
||||
const errors = validateFieldFromHook(
|
||||
processedValue,
|
||||
currentValue,
|
||||
field as unknown as Field<T>
|
||||
).filter((e) => e.type !== ErrorType.Required || isEmpty);
|
||||
).filter((e) => e.type !== ErrorType.Required || valueIsEmpty);
|
||||
|
||||
// Update with new validation results
|
||||
if (errors.length > 0) {
|
||||
newRowErrors[key as string] = errors;
|
||||
} else {
|
||||
// Clear any existing errors for this field
|
||||
delete newRowErrors[key as string];
|
||||
const existing = newRowErrors[String(key)] || [];
|
||||
const sameLength = existing.length === errors.length;
|
||||
const sameContent = sameLength && existing.every((err, idx) => err.message === errors[idx].message && err.type === errors[idx].type);
|
||||
if (!sameContent) {
|
||||
rowChanged = true;
|
||||
newRowErrors[String(key)] = errors;
|
||||
}
|
||||
} else if (newRowErrors[String(key)]) {
|
||||
rowChanged = true;
|
||||
delete newRowErrors[String(key)];
|
||||
}
|
||||
|
||||
// Update the map
|
||||
let resultMap = prev;
|
||||
if (rowChanged) {
|
||||
resultMap = new Map(prev);
|
||||
if (Object.keys(newRowErrors).length > 0) {
|
||||
newMap.set(rowIndex, newRowErrors);
|
||||
resultMap.set(rowIndex, newRowErrors);
|
||||
} else {
|
||||
newMap.delete(rowIndex);
|
||||
resultMap.delete(rowIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// If uniqueness applies, validate affected columns
|
||||
const fieldsToCheck: string[] = [];
|
||||
if (uniquenessFieldKeys.has(String(key))) fieldsToCheck.push(String(key));
|
||||
if (key === ('upc' as T) || key === ('barcode' as T) || key === ('supplier' as T)) {
|
||||
@@ -319,141 +426,116 @@ export const useRowOperations = <T extends string>(
|
||||
}
|
||||
|
||||
if (fieldsToCheck.length > 0) {
|
||||
const dataForCalc = (() => {
|
||||
const copy = [...data];
|
||||
if (rowIndex >= 0 && rowIndex < copy.length) {
|
||||
copy[rowIndex] = { ...(copy[rowIndex] || {}), [key]: processedValue } as RowData<T>;
|
||||
}
|
||||
return copy;
|
||||
})();
|
||||
newMap = mergeUniqueErrorsForFields(newMap, dataForCalc, fieldsToCheck);
|
||||
scheduleUniqueValidation(fieldsToCheck);
|
||||
}
|
||||
|
||||
return newMap;
|
||||
return rowChanged ? resultMap : prev;
|
||||
});
|
||||
});
|
||||
|
||||
// Handle simple secondary effects here
|
||||
setTimeout(() => {
|
||||
// Use __index to find the actual row in the full data array
|
||||
const rowId = rowData.__index;
|
||||
|
||||
// Handle company change - clear line/subline
|
||||
if (key === "company" && processedValue) {
|
||||
// Clear any existing line/subline values
|
||||
setData((prevData) => {
|
||||
const newData = [...prevData];
|
||||
const idx = newData.findIndex((item) => item.__index === rowId);
|
||||
if (key === 'company' && processedValue) {
|
||||
const nextData = [...dataRef.current];
|
||||
const idx = nextData.findIndex((item) => item.__index === rowId);
|
||||
if (idx >= 0) {
|
||||
newData[idx] = {
|
||||
...newData[idx],
|
||||
nextData[idx] = {
|
||||
...nextData[idx],
|
||||
line: undefined,
|
||||
subline: undefined,
|
||||
};
|
||||
}
|
||||
return newData;
|
||||
dataRef.current = nextData;
|
||||
startTransition(() => {
|
||||
setData(() => nextData);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle line change - clear subline
|
||||
if (key === "line" && processedValue) {
|
||||
// Clear any existing subline value
|
||||
setData((prevData) => {
|
||||
const newData = [...prevData];
|
||||
const idx = newData.findIndex((item) => item.__index === rowId);
|
||||
if (key === 'line' && processedValue) {
|
||||
const nextData = [...dataRef.current];
|
||||
const idx = nextData.findIndex((item) => item.__index === rowId);
|
||||
if (idx >= 0) {
|
||||
newData[idx] = {
|
||||
...newData[idx],
|
||||
nextData[idx] = {
|
||||
...nextData[idx],
|
||||
subline: undefined,
|
||||
};
|
||||
}
|
||||
return newData;
|
||||
dataRef.current = nextData;
|
||||
startTransition(() => {
|
||||
setData(() => nextData);
|
||||
});
|
||||
}
|
||||
}, 5); // Reduced delay for faster secondary effects
|
||||
}
|
||||
}, 5);
|
||||
},
|
||||
[data, fields, validateFieldFromHook, setData, setValidationErrors, mergeUniqueErrorsForFields, uniquenessFieldKeys]
|
||||
[fields, scheduleFieldValidation, scheduleUniqueValidation, setData, setValidationErrors, uniquenessFieldKeys, validateFieldFromHook]
|
||||
);
|
||||
|
||||
// Improved revalidateRows function
|
||||
const revalidateRows = useCallback(
|
||||
async (
|
||||
rowIndexes: number[],
|
||||
updatedFields?: { [rowIndex: number]: string[] }
|
||||
) => {
|
||||
// Process all specified rows using a single state update to avoid race conditions
|
||||
const uniqueFieldsToCheck = new Set<string>();
|
||||
const fieldsMarked: Array<[number, string]> = [];
|
||||
|
||||
setValidationErrors((prev) => {
|
||||
let newErrors = new Map(prev);
|
||||
const currentData = dataRef.current;
|
||||
|
||||
// Track which uniqueness fields need to be revalidated across the dataset
|
||||
const uniqueFieldsToCheck = new Set<string>();
|
||||
|
||||
// Process each row
|
||||
for (const rowIndex of rowIndexes) {
|
||||
if (rowIndex < 0 || rowIndex >= data.length) continue;
|
||||
|
||||
const row = data[rowIndex];
|
||||
if (rowIndex < 0 || rowIndex >= currentData.length) continue;
|
||||
const row = currentData[rowIndex];
|
||||
if (!row) continue;
|
||||
|
||||
// If we have specific fields to update for this row
|
||||
const fieldsToValidate = updatedFields?.[rowIndex] || [];
|
||||
|
||||
if (fieldsToValidate.length > 0) {
|
||||
// Get existing errors for this row
|
||||
const existingRowErrors = { ...(newErrors.get(rowIndex) || {}) };
|
||||
|
||||
// Validate each specified field
|
||||
for (const fieldKey of fieldsToValidate) {
|
||||
const field = fields.find((f) => String(f.key) === fieldKey);
|
||||
if (!field) continue;
|
||||
|
||||
const value = row[fieldKey as keyof typeof row];
|
||||
updateValidatingCell(rowIndex, fieldKey, true);
|
||||
fieldsMarked.push([rowIndex, fieldKey]);
|
||||
|
||||
// Run validation for this field
|
||||
const value = row[fieldKey as keyof typeof row];
|
||||
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
|
||||
|
||||
// Update errors for this field
|
||||
if (errors.length > 0) {
|
||||
existingRowErrors[fieldKey] = errors;
|
||||
} else {
|
||||
delete existingRowErrors[fieldKey];
|
||||
}
|
||||
|
||||
// If field is uniqueness-constrained, mark for uniqueness pass
|
||||
if (uniquenessFieldKeys.has(fieldKey)) {
|
||||
uniqueFieldsToCheck.add(fieldKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the row's errors
|
||||
if (Object.keys(existingRowErrors).length > 0) {
|
||||
newErrors.set(rowIndex, existingRowErrors);
|
||||
} else {
|
||||
newErrors.delete(rowIndex);
|
||||
}
|
||||
} else {
|
||||
// No specific fields provided - validate the entire row
|
||||
const rowErrors: Record<string, ValidationError[]> = {};
|
||||
|
||||
// Validate all fields in the row
|
||||
for (const field of fields) {
|
||||
const fieldKey = String(field.key);
|
||||
const value = row[fieldKey as keyof typeof row];
|
||||
|
||||
// Run validation for this field
|
||||
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
|
||||
|
||||
// Update errors for this field
|
||||
if (errors.length > 0) {
|
||||
rowErrors[fieldKey] = errors;
|
||||
}
|
||||
|
||||
// If field is uniqueness-constrained and we validated it, include for uniqueness pass
|
||||
if (uniquenessFieldKeys.has(fieldKey)) {
|
||||
uniqueFieldsToCheck.add(fieldKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the row's errors
|
||||
if (Object.keys(rowErrors).length > 0) {
|
||||
newErrors.set(rowIndex, rowErrors);
|
||||
} else {
|
||||
@@ -462,31 +544,40 @@ export const useRowOperations = <T extends string>(
|
||||
}
|
||||
}
|
||||
|
||||
// Run per-field uniqueness checks and merge results
|
||||
if (uniqueFieldsToCheck.size > 0) {
|
||||
newErrors = mergeUniqueErrorsForFields(newErrors, data, Array.from(uniqueFieldsToCheck));
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
});
|
||||
|
||||
fieldsMarked.forEach(([rowIndex, fieldKey]) => {
|
||||
updateValidatingCell(rowIndex, fieldKey, false);
|
||||
});
|
||||
|
||||
if (uniqueFieldsToCheck.size > 0) {
|
||||
scheduleUniqueValidation(Array.from(uniqueFieldsToCheck));
|
||||
}
|
||||
},
|
||||
[data, fields, validateFieldFromHook, mergeUniqueErrorsForFields, uniquenessFieldKeys]
|
||||
[
|
||||
fields,
|
||||
scheduleUniqueValidation,
|
||||
setValidationErrors,
|
||||
uniquenessFieldKeys,
|
||||
validateFieldFromHook,
|
||||
updateValidatingCell
|
||||
]
|
||||
);
|
||||
|
||||
// Copy a cell value to all cells below it in the same column
|
||||
const copyDown = useCallback(
|
||||
(rowIndex: number, key: T) => {
|
||||
// Get the source value to copy
|
||||
const sourceValue = data[rowIndex][key];
|
||||
const currentData = dataRef.current;
|
||||
const sourceRow = currentData[rowIndex];
|
||||
if (!sourceRow) return;
|
||||
|
||||
// Update all rows below with the same value using the existing updateRow function
|
||||
// This ensures all validation logic runs consistently
|
||||
for (let i = rowIndex + 1; i < data.length; i++) {
|
||||
// Just use updateRow which will handle validation with proper timing
|
||||
const sourceValue = sourceRow[key];
|
||||
|
||||
for (let i = rowIndex + 1; i < currentData.length; i++) {
|
||||
updateRow(i, key, sourceValue);
|
||||
}
|
||||
},
|
||||
[data, updateRow]
|
||||
[updateRow]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, startTransition } from "react";
|
||||
import { useRsi } from "../../../hooks/useRsi";
|
||||
import { ErrorType } from "../../../types";
|
||||
import { RowSelectionState } from "@tanstack/react-table";
|
||||
@@ -11,7 +11,7 @@ import { useTemplateManagement } from "./useTemplateManagement";
|
||||
import { useFilterManagement } from "./useFilterManagement";
|
||||
import { useUniqueItemNumbersValidation } from "./useUniqueItemNumbersValidation";
|
||||
import { useUpcValidation } from "./useUpcValidation";
|
||||
import { Props, RowData } from "./validationTypes";
|
||||
import { Props, RowData, isEmpty as isValueEmpty } from "./validationTypes";
|
||||
|
||||
// Country normalization helper (common mappings) - function declaration for hoisting
|
||||
function normalizeCountryCode(input: string): string | null {
|
||||
@@ -145,6 +145,7 @@ export const useValidationState = <T extends string>({
|
||||
// isValidatingRef unused; remove to satisfy TS
|
||||
// Track last seen item_number signature to drive targeted uniqueness checks
|
||||
const lastItemNumberSigRef = useRef<string | null>(null);
|
||||
const pendingItemNumberValidationRef = useRef<{ cancel: () => void } | null>(null);
|
||||
|
||||
// Use row operations hook
|
||||
const { validateRow, updateRow, revalidateRows, copyDown } = useRowOperations<T>(
|
||||
@@ -152,7 +153,8 @@ export const useValidationState = <T extends string>({
|
||||
fields,
|
||||
setData,
|
||||
setValidationErrors,
|
||||
validateFieldFromHook
|
||||
validateFieldFromHook,
|
||||
setValidatingCells
|
||||
);
|
||||
|
||||
// Use UPC validation hook - MUST be initialized before template management
|
||||
@@ -306,148 +308,63 @@ export const useValidationState = <T extends string>({
|
||||
// Initialize validation once, after initial UPC-based item number generation completes
|
||||
useEffect(() => {
|
||||
if (initialValidationDoneRef.current) return;
|
||||
// Wait for initial UPC validation to finish to avoid double work and ensure
|
||||
// item_number values are in place before uniqueness checks
|
||||
if (!upcValidation.initialValidationDone) return;
|
||||
|
||||
const runCompleteValidation = async () => {
|
||||
if (!data || data.length === 0) return;
|
||||
|
||||
console.log("Running complete validation...");
|
||||
|
||||
// Get required fields
|
||||
const requiredFields = fields.filter((field) =>
|
||||
field.validations?.some((v) => v.rule === "required")
|
||||
);
|
||||
console.log(`Found ${requiredFields.length} required fields`);
|
||||
|
||||
// Get fields that have regex validation
|
||||
const regexFields = fields.filter((field) =>
|
||||
field.validations?.some((v) => v.rule === "regex")
|
||||
);
|
||||
console.log(`Found ${regexFields.length} fields with regex validation`);
|
||||
|
||||
// Get fields that need uniqueness validation
|
||||
const uniqueFields = fields.filter((field) =>
|
||||
field.validations?.some((v) => v.rule === "unique")
|
||||
);
|
||||
console.log(
|
||||
`Found ${uniqueFields.length} fields requiring uniqueness validation`
|
||||
);
|
||||
|
||||
// Dynamic batch size based on dataset size
|
||||
const BATCH_SIZE = data.length <= 50 ? data.length : 25; // Process all at once for small datasets
|
||||
const validationErrorsTemp = new Map<number, Record<string, any[]>>();
|
||||
const mutatedRows: Array<[number, RowData<T>]> = [];
|
||||
const totalRows = data.length;
|
||||
let currentIndex = 0;
|
||||
let cancelled = false;
|
||||
|
||||
// Initialize new data for any modifications
|
||||
const newData = [...data];
|
||||
const cleanupCallbacks = new Set<() => void>();
|
||||
|
||||
// Create a temporary Map to collect all validation errors
|
||||
const validationErrorsTemp = new Map<
|
||||
number,
|
||||
Record<string, any[]>
|
||||
>();
|
||||
|
||||
// Variables for batching
|
||||
let currentBatch = 0;
|
||||
const totalBatches = Math.ceil(totalRows / BATCH_SIZE);
|
||||
|
||||
const processBatch = async () => {
|
||||
// Calculate batch range
|
||||
const startIdx = currentBatch * BATCH_SIZE;
|
||||
const endIdx = Math.min(startIdx + BATCH_SIZE, totalRows);
|
||||
console.log(
|
||||
`Processing batch ${
|
||||
currentBatch + 1
|
||||
}/${totalBatches} (rows ${startIdx} to ${endIdx - 1})`
|
||||
);
|
||||
|
||||
// Process rows in this batch
|
||||
const batchPromises: Promise<void>[] = [];
|
||||
|
||||
for (let rowIndex = startIdx; rowIndex < endIdx; rowIndex++) {
|
||||
batchPromises.push(
|
||||
new Promise<void>((resolve) => {
|
||||
const processRow = (rowIndex: number) => {
|
||||
const row = data[rowIndex];
|
||||
if (!row) return;
|
||||
|
||||
// Skip if row is empty or undefined
|
||||
if (!row) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Store field errors for this row
|
||||
const fieldErrors: Record<string, any[]> = {};
|
||||
const rowErrors: Record<string, any[]> = {};
|
||||
let hasErrors = false;
|
||||
|
||||
// Check if price fields need formatting
|
||||
const rowAsRecord = row as Record<string, any>;
|
||||
let mSrpNeedsProcessing = false;
|
||||
let costEachNeedsProcessing = false;
|
||||
let updatedRow: Record<string, any> | null = null;
|
||||
|
||||
if (
|
||||
rowAsRecord.msrp &&
|
||||
typeof rowAsRecord.msrp === "string" &&
|
||||
(rowAsRecord.msrp.includes("$") ||
|
||||
rowAsRecord.msrp.includes(","))
|
||||
) {
|
||||
mSrpNeedsProcessing = true;
|
||||
const ensureUpdatedRow = () => {
|
||||
if (!updatedRow) {
|
||||
updatedRow = { ...rowAsRecord };
|
||||
}
|
||||
return updatedRow;
|
||||
};
|
||||
|
||||
if (
|
||||
rowAsRecord.cost_each &&
|
||||
typeof rowAsRecord.cost_each === "string" &&
|
||||
(rowAsRecord.cost_each.includes("$") ||
|
||||
rowAsRecord.cost_each.includes(","))
|
||||
) {
|
||||
costEachNeedsProcessing = true;
|
||||
}
|
||||
|
||||
// Process price fields if needed
|
||||
if (mSrpNeedsProcessing || costEachNeedsProcessing) {
|
||||
// Create a clean copy only if needed
|
||||
const cleanedRow = { ...row } as Record<string, any>;
|
||||
|
||||
if (mSrpNeedsProcessing) {
|
||||
if (typeof rowAsRecord.msrp === "string" && /[$,]/.test(rowAsRecord.msrp)) {
|
||||
const msrpValue = rowAsRecord.msrp.replace(/[$,]/g, "");
|
||||
const numValue = parseFloat(msrpValue);
|
||||
cleanedRow.msrp = !isNaN(numValue)
|
||||
? numValue.toFixed(2)
|
||||
: msrpValue;
|
||||
ensureUpdatedRow().msrp = Number.isNaN(numValue) ? msrpValue : numValue.toFixed(2);
|
||||
}
|
||||
|
||||
if (costEachNeedsProcessing) {
|
||||
if (typeof rowAsRecord.cost_each === "string" && /[$,]/.test(rowAsRecord.cost_each)) {
|
||||
const costValue = rowAsRecord.cost_each.replace(/[$,]/g, "");
|
||||
const numValue = parseFloat(costValue);
|
||||
cleanedRow.cost_each = !isNaN(numValue)
|
||||
? numValue.toFixed(2)
|
||||
: costValue;
|
||||
ensureUpdatedRow().cost_each = Number.isNaN(numValue) ? costValue : numValue.toFixed(2);
|
||||
}
|
||||
|
||||
newData[rowIndex] = cleanedRow as RowData<T>;
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
for (const field of requiredFields) {
|
||||
const key = String(field.key);
|
||||
const value = row[key as keyof typeof row];
|
||||
const value = rowAsRecord[key];
|
||||
|
||||
// Skip non-required empty fields
|
||||
if (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === "" ||
|
||||
(Array.isArray(value) && value.length === 0) ||
|
||||
(typeof value === "object" &&
|
||||
value !== null &&
|
||||
Object.keys(value).length === 0)
|
||||
) {
|
||||
// Add error for empty required fields
|
||||
fieldErrors[key] = [
|
||||
if (isValueEmpty(value)) {
|
||||
rowErrors[key] = [
|
||||
{
|
||||
message:
|
||||
field.validations?.find((v) => v.rule === "required")
|
||||
?.errorMessage || "This field is required",
|
||||
field.validations?.find((v) => v.rule === "required")?.errorMessage ||
|
||||
"This field is required",
|
||||
level: "error",
|
||||
source: "row",
|
||||
type: "required",
|
||||
@@ -457,30 +374,18 @@ export const useValidationState = <T extends string>({
|
||||
}
|
||||
}
|
||||
|
||||
// Validate regex fields - even if they have data
|
||||
for (const field of regexFields) {
|
||||
const key = String(field.key);
|
||||
const value = row[key as keyof typeof row];
|
||||
const value = rowAsRecord[key];
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
|
||||
// Skip empty values as they're handled by required validation
|
||||
if (value === undefined || value === null || value === "") {
|
||||
continue;
|
||||
}
|
||||
const regexValidation = field.validations?.find((v) => v.rule === "regex");
|
||||
if (!regexValidation) continue;
|
||||
|
||||
// Find regex validation
|
||||
const regexValidation = field.validations?.find(
|
||||
(v) => v.rule === "regex"
|
||||
);
|
||||
if (regexValidation) {
|
||||
try {
|
||||
// Check if value matches regex
|
||||
const regex = new RegExp(
|
||||
regexValidation.value,
|
||||
regexValidation.flags
|
||||
);
|
||||
const regex = new RegExp(regexValidation.value, regexValidation.flags);
|
||||
if (!regex.test(String(value))) {
|
||||
// Add regex validation error
|
||||
fieldErrors[key] = [
|
||||
rowErrors[key] = [
|
||||
{
|
||||
message: regexValidation.errorMessage,
|
||||
level: regexValidation.level || "error",
|
||||
@@ -494,84 +399,143 @@ export const useValidationState = <T extends string>({
|
||||
console.error("Invalid regex in validation:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedRow) {
|
||||
mutatedRows.push([rowIndex, updatedRow as RowData<T>]);
|
||||
}
|
||||
|
||||
// Update validation errors for this row
|
||||
if (hasErrors) {
|
||||
validationErrorsTemp.set(rowIndex, fieldErrors);
|
||||
validationErrorsTemp.set(rowIndex, rowErrors);
|
||||
}
|
||||
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for all row validations to complete
|
||||
await Promise.all(batchPromises);
|
||||
};
|
||||
|
||||
const processAllBatches = async () => {
|
||||
for (let batch = 0; batch < totalBatches; batch++) {
|
||||
currentBatch = batch;
|
||||
await processBatch();
|
||||
const finalize = () => {
|
||||
if (cancelled) return;
|
||||
|
||||
// Yield to UI thread more frequently for large datasets
|
||||
if (batch % 2 === 1 || totalRows > 500) {
|
||||
await new Promise((resolve) => setTimeout(resolve, totalRows > 1000 ? 10 : 5));
|
||||
}
|
||||
startTransition(() => {
|
||||
setValidationErrors(new Map(validationErrorsTemp));
|
||||
});
|
||||
|
||||
if (mutatedRows.length > 0) {
|
||||
setData((prev) => {
|
||||
if (cancelled) return prev;
|
||||
let applied = false;
|
||||
const next = [...prev];
|
||||
|
||||
mutatedRows.forEach(([index, updatedRow]) => {
|
||||
if (index < 0 || index >= prev.length) return;
|
||||
if (prev[index] !== data[index]) return;
|
||||
next[index] = updatedRow;
|
||||
applied = true;
|
||||
});
|
||||
|
||||
return applied ? next : prev;
|
||||
});
|
||||
}
|
||||
|
||||
// All batches complete
|
||||
console.log("All initial validation batches complete");
|
||||
|
||||
// Apply collected validation errors all at once
|
||||
setValidationErrors(validationErrorsTemp);
|
||||
|
||||
// Apply any data changes (like price formatting)
|
||||
if (JSON.stringify(data) !== JSON.stringify(newData)) {
|
||||
setData(newData);
|
||||
}
|
||||
|
||||
// Run uniqueness validation after the basic validation
|
||||
validateUniqueItemNumbers();
|
||||
|
||||
// Mark that initial validation is done
|
||||
initialValidationDoneRef.current = true;
|
||||
|
||||
console.log("Initial validation complete");
|
||||
};
|
||||
|
||||
// Start the validation process
|
||||
processAllBatches();
|
||||
const runChunk = (deadline?: IdleDeadline) => {
|
||||
if (cancelled) return;
|
||||
|
||||
let iterations = 0;
|
||||
const maxIterationsPerChunk = 20;
|
||||
|
||||
while (currentIndex < totalRows) {
|
||||
if (deadline) {
|
||||
if (deadline.timeRemaining() <= 0 && iterations > 0) break;
|
||||
} else if (iterations >= maxIterationsPerChunk) {
|
||||
break;
|
||||
}
|
||||
|
||||
processRow(currentIndex);
|
||||
currentIndex += 1;
|
||||
iterations += 1;
|
||||
}
|
||||
|
||||
if (currentIndex >= totalRows) {
|
||||
finalize();
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleNext();
|
||||
};
|
||||
|
||||
// Run the complete validation
|
||||
runCompleteValidation();
|
||||
const scheduleNext = () => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const win = window as Window & typeof globalThis & {
|
||||
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
};
|
||||
|
||||
if (win.requestIdleCallback) {
|
||||
let cancel: () => void = () => {};
|
||||
const handle = win.requestIdleCallback((deadline) => {
|
||||
cleanupCallbacks.delete(cancel);
|
||||
runChunk(deadline);
|
||||
}, { timeout: 250 });
|
||||
cancel = () => win.cancelIdleCallback?.(handle);
|
||||
cleanupCallbacks.add(cancel);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancel: () => void = () => {};
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
cleanupCallbacks.delete(cancel);
|
||||
runChunk();
|
||||
}, 16);
|
||||
cancel = () => window.clearTimeout(timeoutId);
|
||||
cleanupCallbacks.add(cancel);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => runChunk(), 0);
|
||||
};
|
||||
|
||||
scheduleNext();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanupCallbacks.forEach((cancel) => cancel());
|
||||
cleanupCallbacks.clear();
|
||||
};
|
||||
}, [data, fields, setData, setValidationErrors, validateUniqueItemNumbers, upcValidation.initialValidationDone]);
|
||||
|
||||
// Targeted uniqueness revalidation: run only when item_number values change
|
||||
useEffect(() => {
|
||||
if (!data || data.length === 0) return;
|
||||
|
||||
// Build a simple signature of the item_number column
|
||||
const sig = data.map((r) => String((r as Record<string, any>).item_number ?? '')).join('|');
|
||||
const sig = data
|
||||
.map((r) => String((r as Record<string, any>).item_number ?? ''))
|
||||
.join('|');
|
||||
if (lastItemNumberSigRef.current === sig) return;
|
||||
lastItemNumberSigRef.current = sig;
|
||||
|
||||
// Compute unique errors for item_number only and merge
|
||||
const uniqueMap = validateUniqueField(data, 'item_number');
|
||||
pendingItemNumberValidationRef.current?.cancel();
|
||||
|
||||
let cancelled = false;
|
||||
const currentData = data;
|
||||
|
||||
const runValidation = () => {
|
||||
if (cancelled) return;
|
||||
|
||||
const uniqueMap = validateUniqueField(currentData, 'item_number');
|
||||
const rowsWithUnique = new Set<number>();
|
||||
uniqueMap.forEach((_, idx) => rowsWithUnique.add(idx));
|
||||
|
||||
startTransition(() => {
|
||||
setValidationErrors((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
|
||||
// Apply unique errors
|
||||
uniqueMap.forEach((errorsForRow, rowIdx) => {
|
||||
const existing = { ...(newMap.get(rowIdx) || {}) } as Record<string, any[]>;
|
||||
const info = (errorsForRow as any)['item_number'];
|
||||
const currentValue = (data[rowIdx] as any)?.['item_number'];
|
||||
// Only apply uniqueness error when the value is non-empty
|
||||
const currentValue = (currentData[rowIdx] as any)?.['item_number'];
|
||||
|
||||
if (info && currentValue !== undefined && currentValue !== null && String(currentValue) !== '') {
|
||||
existing['item_number'] = [
|
||||
{
|
||||
@@ -582,25 +546,30 @@ export const useValidationState = <T extends string>({
|
||||
},
|
||||
];
|
||||
}
|
||||
// If value is now present, make sure to clear any lingering Required error
|
||||
|
||||
if (currentValue !== undefined && currentValue !== null && String(currentValue) !== '' && existing['item_number']) {
|
||||
existing['item_number'] = (existing['item_number'] as any[]).filter((e) => e.type !== ErrorType.Required);
|
||||
if ((existing['item_number'] as any[]).length === 0) delete existing['item_number'];
|
||||
}
|
||||
|
||||
if (Object.keys(existing).length > 0) newMap.set(rowIdx, existing);
|
||||
else newMap.delete(rowIdx);
|
||||
});
|
||||
|
||||
// Remove stale unique errors for rows no longer duplicated
|
||||
newMap.forEach((rowErrs, rowIdx) => {
|
||||
const currentValue = (data[rowIdx] as any)?.['item_number'];
|
||||
const shouldRemoveUnique = !rowsWithUnique.has(rowIdx) || currentValue === undefined || currentValue === null || String(currentValue) === '';
|
||||
const currentValue = (currentData[rowIdx] as any)?.['item_number'];
|
||||
const shouldRemoveUnique =
|
||||
!rowsWithUnique.has(rowIdx) ||
|
||||
currentValue === undefined ||
|
||||
currentValue === null ||
|
||||
String(currentValue) === '';
|
||||
|
||||
if (shouldRemoveUnique && (rowErrs as any)['item_number']) {
|
||||
const filtered = (rowErrs as any)['item_number'].filter((e: any) => e.type !== ErrorType.Unique);
|
||||
if (filtered.length > 0) (rowErrs as any)['item_number'] = filtered;
|
||||
else delete (rowErrs as any)['item_number'];
|
||||
}
|
||||
// If value now present, also clear any lingering Required error for this field
|
||||
|
||||
if (currentValue !== undefined && currentValue !== null && String(currentValue) !== '' && (rowErrs as any)['item_number']) {
|
||||
const nonRequired = (rowErrs as any)['item_number'].filter((e: any) => e.type !== ErrorType.Required);
|
||||
if (nonRequired.length > 0) (rowErrs as any)['item_number'] = nonRequired;
|
||||
@@ -613,6 +582,51 @@ export const useValidationState = <T extends string>({
|
||||
|
||||
return newMap;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const schedule = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
runValidation();
|
||||
return;
|
||||
}
|
||||
|
||||
const win = window as Window & typeof globalThis & {
|
||||
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
};
|
||||
|
||||
if (win.requestIdleCallback) {
|
||||
const handle = win.requestIdleCallback(() => {
|
||||
pendingItemNumberValidationRef.current = null;
|
||||
if (cancelled) return;
|
||||
runValidation();
|
||||
}, { timeout: 250 });
|
||||
|
||||
pendingItemNumberValidationRef.current = {
|
||||
cancel: () => win.cancelIdleCallback?.(handle),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
pendingItemNumberValidationRef.current = null;
|
||||
if (cancelled) return;
|
||||
runValidation();
|
||||
}, 16);
|
||||
|
||||
pendingItemNumberValidationRef.current = {
|
||||
cancel: () => window.clearTimeout(timeoutId),
|
||||
};
|
||||
};
|
||||
|
||||
schedule();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
pendingItemNumberValidationRef.current?.cancel();
|
||||
pendingItemNumberValidationRef.current = null;
|
||||
};
|
||||
}, [data, validateUniqueField, setValidationErrors]);
|
||||
|
||||
// Update fields with latest options
|
||||
|
||||
Reference in New Issue
Block a user