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