Make tax cat single select, plus revert previous commit "Attempted improvements to validation to make the validation step table more responsive"

This commit is contained in:
2025-09-24 09:14:58 -04:00
parent 24aee1db90
commit 138251cf86
8 changed files with 641 additions and 796 deletions

View File

@@ -11,6 +11,7 @@ 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<{
@@ -79,8 +80,7 @@ const BaseCellContent = React.memo(({
className = '',
fieldKey = '',
onStartEdit,
onEndEdit,
isValidating
onEndEdit
}: {
field: Field<string>;
value: any;
@@ -91,7 +91,6 @@ const BaseCellContent = React.memo(({
fieldKey?: string;
onStartEdit?: () => void;
onEndEdit?: () => void;
isValidating?: boolean;
}) => {
// Get field type information
const fieldType = fieldKey === 'line' || fieldKey === 'subline'
@@ -124,7 +123,6 @@ const BaseCellContent = React.memo(({
hasErrors={hasErrors}
className={className}
disabled={field.disabled}
isValidating={isValidating}
/>
);
}
@@ -141,7 +139,6 @@ const BaseCellContent = React.memo(({
hasErrors={hasErrors}
className={className}
disabled={field.disabled}
isValidating={isValidating}
/>
);
}
@@ -158,7 +155,6 @@ const BaseCellContent = React.memo(({
hasErrors={hasErrors}
className={className}
disabled={field.disabled}
isValidating={isValidating}
/>
);
}
@@ -174,7 +170,6 @@ const BaseCellContent = React.memo(({
isMultiline={isMultiline}
isPrice={isPrice}
disabled={field.disabled}
isValidating={isValidating}
/>
);
}, (prev, next) => {
@@ -471,8 +466,13 @@ 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={`relative truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}
className={`truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}
style={{
backgroundColor: isSourceCell ? '#dbeafe' :
isSelectedTarget ? '#bfdbfe' :
@@ -492,12 +492,9 @@ const ValidationCell = React.memo(({
fieldKey={fieldKey}
onStartEdit={handleStartEdit}
onEndEdit={handleEndEdit}
isValidating={isLoading}
/>
{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>
);

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useCallback, useState, useEffect, useRef } from 'react'
import React, { useMemo, useCallback, useState } from 'react'
import {
useReactTable,
getCoreRowModel,
@@ -6,7 +6,6 @@ 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'
@@ -194,14 +193,6 @@ 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);
@@ -402,9 +393,6 @@ 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]
@@ -483,7 +471,7 @@ const ValidationTable = <T extends string>({
value={currentValue}
onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)}
errors={cellErrors}
isValidating={isLoading || isCellValidating}
isValidating={isLoading}
fieldKey={fieldKey}
options={options}
itemNumber={itemNumber}
@@ -517,47 +505,6 @@ 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);
@@ -578,7 +525,7 @@ const ValidationTable = <T extends string>({
return (
<CopyDownContext.Provider value={copyDownContextValue}>
<div ref={tableRootRef} className="min-w-max relative">
<div className="min-w-max relative">
{/* Add global styles for copy down mode */}
{isInCopyDownMode && (
<style>
@@ -683,17 +630,7 @@ const ValidationTable = <T extends string>({
transform: 'translateZ(0)' // Force GPU acceleration
}}>
<TableBody>
{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;
{table.getRowModel().rows.map((row) => {
// Precompute validation error status for this row
const hasErrors = validationErrors.has(parseInt(row.id)) &&
Object.keys(validationErrors.get(parseInt(row.id)) || {}).length > 0;
@@ -722,7 +659,6 @@ 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) => (
@@ -733,14 +669,6 @@ 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>

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react'
import React, { useState, useCallback, useMemo } from 'react'
import { Field } from '../../../../types'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
@@ -15,10 +15,8 @@ interface InputCellProps<T extends string> {
isPrice?: boolean
disabled?: boolean
className?: string
isValidating?: boolean
}
// (removed unused formatPrice helper)
const InputCell = <T extends string>({
@@ -31,12 +29,11 @@ const InputCell = <T extends string>({
isMultiline = false,
isPrice = false,
disabled = false,
className = '', isValidating: _isValidating = false
className = ''
}: 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
@@ -51,7 +48,6 @@ const InputCell = <T extends string>({
// Handle focus event
const handleFocus = useCallback(() => {
setIsEditing(true);
setPendingDisplayValue(null);
if (value !== undefined && value !== null) {
if (isPrice) {
@@ -72,8 +68,6 @@ 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);
@@ -88,28 +82,22 @@ const InputCell = <T extends string>({
setEditValue(newValue);
}, [isPrice]);
useEffect(() => {
if (pendingDisplayValue === null) return;
const currentValue = value ?? '';
if (String(currentValue) === pendingDisplayValue) {
setPendingDisplayValue(null);
}
}, [value, pendingDisplayValue]);
// Get the display value - prefer pending value when present for immediate feedback
// Get the display value - use parent value directly
const displayValue = useMemo(() => {
const rawValue = pendingDisplayValue !== null ? pendingDisplayValue : value ?? '';
const currentValue = 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);
// 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);
}
}
return String(rawValue);
}, [isPrice, value, pendingDisplayValue]);
// For non-price or invalid price values, return as-is
return String(currentValue);
}, [isPrice, value]);
// Add outline even when not in focus
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0";

View File

@@ -24,10 +24,8 @@ 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,
@@ -160,7 +158,7 @@ const MultiSelectCell = <T extends string>({
hasErrors,
options: providedOptions,
disabled = false,
className = '', isValidating: _isValidating = false
className = ''
}: MultiSelectCellProps<T>) => {
const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")

View File

@@ -22,10 +22,8 @@ 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,
@@ -36,7 +34,7 @@ const SelectCell = <T extends string>({
hasErrors,
options = [],
disabled = false,
className = '', isValidating = false
className = ''
}: SelectCellProps<T>) => {
// State for the open/closed state of the dropdown
const [open, setOpen] = useState(false);
@@ -49,7 +47,6 @@ 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);
@@ -64,10 +61,8 @@ const SelectCell = <T extends string>({
useEffect(() => {
setInternalValue(value);
// When the value prop changes, it means validation is complete
if (!isValidating) {
setIsProcessing(false);
}
}, [value, isValidating]);
}, [value]);
// Memoize options processing to avoid recalculation on every render
const selectOptions = useMemo(() => {
@@ -149,9 +144,7 @@ const SelectCell = <T extends string>({
// 6. Clear processing state after a short delay - reduced for responsiveness
setTimeout(() => {
if (!isValidating) {
setIsProcessing(false);
}
}, 50);
}, [onChange, onEndEdit]);
@@ -207,7 +200,7 @@ const SelectCell = <T extends string>({
"w-full justify-between font-normal",
"border",
!internalValue && "text-muted-foreground",
showProcessing && "text-muted-foreground",
isProcessing && "text-muted-foreground",
hasErrors ? "border-destructive" : "",
className
)}
@@ -240,7 +233,7 @@ const SelectCell = <T extends string>({
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<span className={showProcessing ? "opacity-70" : ""}>
<span className={isProcessing ? "opacity-70" : ""}>
{displayValue}
</span>
<ChevronsUpDown className="mr-1.5 h-4 w-4 shrink-0 opacity-50" />

View File

@@ -1,100 +1,21 @@
import { useCallback, useEffect, useMemo, useRef, startTransition } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { RowData, isEmpty as isValueEmpty } from './validationTypes';
import { useCallback, useMemo } from 'react';
import { RowData } 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[],
setValidatingCells?: Dispatch<SetStateAction<Set<string>>>
validateFieldFromHook: (value: any, field: Field<T>) => ValidationError[]
) => {
// Uniqueness validation utilities
const { validateUniqueField } = useUniqueValidation<T>(fields);
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();
};
}, []);
// Determine which field keys are considered uniqueness-constrained
const uniquenessFieldKeys = useMemo(() => {
const keys = new Set<string>([
'item_number',
@@ -104,16 +25,15 @@ export const useRowOperations = <T extends string>(
'notions_no',
'name'
]);
fields.forEach((field) => {
if (field.validations?.some((v) => v.rule === 'unique')) {
keys.add(String(field.key));
fields.forEach((f) => {
if (f.validations?.some((v) => v.rule === 'unique')) {
keys.add(String(f.key));
}
});
return keys;
}, [fields]);
// Merge per-field uniqueness errors into the validation error map
const mergeUniqueErrorsForFields = useCallback(
(
baseErrors: Map<number, Record<string, ValidationError[]>>,
@@ -124,19 +44,26 @@ 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) || {}) };
const info = errorsForRow[fieldKey];
const currentValue = (dataForCalc[rowIdx] as any)?.[fieldKey];
if (info && !isValueEmpty(currentValue)) {
// 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)) {
existing[fieldKey] = [
{
message: info.message,
@@ -151,7 +78,9 @@ 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]) {
@@ -170,255 +99,219 @@ export const useRowOperations = <T extends string>(
[uniquenessFieldKeys, validateUniqueField]
);
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]
);
// Helper function to validate a field value
const fieldValidationHelper = useCallback(
(rowIndex: number, specificField?: string) => {
const currentData = dataRef.current;
if (rowIndex < 0 || rowIndex >= currentData.length) return;
// Skip validation if row doesn't exist
if (rowIndex < 0 || rowIndex >= data.length) return;
const row = currentData[rowIndex];
// Get the row data
const row = data[rowIndex];
// If validating a specific field, only check that field
if (specificField) {
const field = fields.find((f) => String(f.key) === specificField);
if (!field) return;
if (field) {
const value = row[specificField as keyof typeof row];
updateValidatingCell(rowIndex, specificField, true);
// Use state setter instead of direct mutation
setValidationErrors((prev) => {
const existingErrors = prev.get(rowIndex) || {};
const newRowErrors = { ...existingErrors };
let rowChanged = false;
let newErrors = new Map(prev);
const existingErrors = { ...(newErrors.get(rowIndex) || {}) };
const isRequired = field.validations?.some((v) => v.rule === 'required');
const valueIsEmpty =
// Quick check for required fields - this prevents flashing errors
const isRequired = field.validations?.some(
(v) => v.rule === "required"
);
const isEmpty =
value === undefined ||
value === null ||
value === '' ||
value === "" ||
(Array.isArray(value) && 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);
(typeof value === "object" &&
value !== null &&
Object.keys(value).length === 0);
// 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) {
rowChanged = true;
delete newRowErrors[specificField];
} else if (nonRequiredErrors.length !== newRowErrors[specificField].length) {
rowChanged = true;
newRowErrors[specificField] = nonRequiredErrors;
// If no other errors, remove the field entirely from errors
delete existingErrors[specificField];
} else {
existingErrors[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) {
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];
}
let resultMap = prev;
if (rowChanged) {
resultMap = new Map(prev);
if (Object.keys(newRowErrors).length > 0) {
resultMap.set(rowIndex, newRowErrors);
existingErrors[specificField] = errors;
} else {
resultMap.delete(rowIndex);
}
delete existingErrors[specificField];
}
// Update validation errors map
if (Object.keys(existingErrors).length > 0) {
newErrors.set(rowIndex, existingErrors);
} else {
newErrors.delete(rowIndex);
}
// If field is uniqueness-constrained, also re-validate uniqueness for the column
if (uniquenessFieldKeys.has(specificField)) {
scheduleUniqueValidation([specificField]);
return rowChanged ? resultMap : prev;
const dataForCalc = data; // latest data
newErrors = mergeUniqueErrorsForFields(newErrors, dataForCalc, [specificField]);
}
return rowChanged ? resultMap : prev;
return newErrors;
});
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 valueForField = row[fieldKey as keyof typeof row];
const errors = validateFieldFromHook(valueForField, field as unknown as Field<T>);
const value = row[fieldKey as keyof typeof row];
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
if (errors.length > 0) {
rowErrors[fieldKey] = errors;
}
});
if (Object.keys(rowErrors).length === 0) {
if (!prev.has(rowIndex)) return prev;
const result = new Map(prev);
result.delete(rowIndex);
return result;
// Update validation errors map
if (Object.keys(rowErrors).length > 0) {
newErrors.set(rowIndex, rowErrors);
} else {
newErrors.delete(rowIndex);
}
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)
);
return newErrors;
});
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);
}
}
},
[fields, scheduleUniqueValidation, setValidationErrors, uniquenessFieldKeys, validateFieldFromHook]
[data, fields, validateFieldFromHook, setValidationErrors, mergeUniqueErrorsForFields, uniquenessFieldKeys]
);
// 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;
if ((key === 'msrp' || key === 'cost_each') && typeof value === 'string') {
processedValue = value.replace(/[$,]/g, '');
// 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
const numValue = parseFloat(processedValue);
if (!Number.isNaN(numValue)) {
if (!isNaN(numValue)) {
processedValue = numValue.toFixed(2);
}
}
const currentData = dataRef.current;
const rowData = currentData[rowIndex];
// Find the row data first
const rowData = data[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 };
const nextData = [...currentData];
if (rowIndex >= 0 && rowIndex < nextData.length) {
nextData[rowIndex] = updatedRow;
// Update the data immediately - this sets the value
setData((prevData) => {
const newData = [...prevData];
if (rowIndex >= 0 && rowIndex < newData.length) {
newData[rowIndex] = updatedRow;
}
dataRef.current = nextData;
startTransition(() => {
setData(() => nextData);
return newData;
});
// Find the field definition
const field = fields.find((f) => String(f.key) === key);
if (!field) return;
scheduleFieldValidation(rowIndex, String(key), () => {
// CRITICAL FIX: Combine both validation operations into a single state update
// to prevent intermediate rendering that causes error icon flashing
setValidationErrors((prev) => {
const existingErrors = prev.get(rowIndex) || {};
// Start with previous errors
let newMap = new Map(prev);
const existingErrors = newMap.get(rowIndex) || {};
const newRowErrors = { ...existingErrors };
let rowChanged = false;
const latestRow = dataRef.current[rowIndex];
const currentValue = latestRow ? (latestRow[String(key) as keyof typeof latestRow] as unknown) : processedValue;
// 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 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);
// 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
);
if (isRequired && !valueIsEmpty && newRowErrors[String(key)]) {
const nonRequiredErrors = newRowErrors[String(key)].filter((e) => e.type !== ErrorType.Required);
if (nonRequiredErrors.length === 0) {
if (newRowErrors[String(key)]) {
rowChanged = true;
delete newRowErrors[String(key)];
}
} else if (nonRequiredErrors.length !== newRowErrors[String(key)].length) {
rowChanged = true;
newRowErrors[String(key)] = nonRequiredErrors;
}
}
const errors = validateFieldFromHook(
currentValue,
field as unknown as Field<T>
).filter((e) => e.type !== ErrorType.Required || valueIsEmpty);
if (errors.length > 0) {
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)];
}
let resultMap = prev;
if (rowChanged) {
resultMap = new Map(prev);
if (Object.keys(newRowErrors).length > 0) {
resultMap.set(rowIndex, newRowErrors);
// If no other errors, delete the field's errors entirely
delete newRowErrors[key as string];
} else {
resultMap.delete(rowIndex);
// Otherwise keep non-required errors
newRowErrors[key as string] = nonRequiredErrors;
}
}
}
// Now run full validation for the field (except for required which we already handled)
const errors = validateFieldFromHook(
processedValue,
field as unknown as Field<T>
).filter((e) => e.type !== ErrorType.Required || isEmpty);
// 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];
}
// Update the map
if (Object.keys(newRowErrors).length > 0) {
newMap.set(rowIndex, newRowErrors);
} else {
newMap.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)) {
@@ -426,116 +319,141 @@ export const useRowOperations = <T extends string>(
}
if (fieldsToCheck.length > 0) {
scheduleUniqueValidation(fieldsToCheck);
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);
}
return rowChanged ? resultMap : prev;
});
return newMap;
});
// Handle simple secondary effects here
setTimeout(() => {
// Use __index to find the actual row in the full data array
const rowId = rowData.__index;
if (key === 'company' && processedValue) {
const nextData = [...dataRef.current];
const idx = nextData.findIndex((item) => item.__index === rowId);
// 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 (idx >= 0) {
nextData[idx] = {
...nextData[idx],
newData[idx] = {
...newData[idx],
line: undefined,
subline: undefined,
};
dataRef.current = nextData;
startTransition(() => {
setData(() => nextData);
}
return newData;
});
}
}
if (key === 'line' && processedValue) {
const nextData = [...dataRef.current];
const idx = nextData.findIndex((item) => item.__index === rowId);
// 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 (idx >= 0) {
nextData[idx] = {
...nextData[idx],
newData[idx] = {
...newData[idx],
subline: undefined,
};
dataRef.current = nextData;
startTransition(() => {
setData(() => nextData);
}
return newData;
});
}
}
}, 5);
}, 5); // Reduced delay for faster secondary effects
},
[fields, scheduleFieldValidation, scheduleUniqueValidation, setData, setValidationErrors, uniquenessFieldKeys, validateFieldFromHook]
[data, fields, validateFieldFromHook, setData, setValidationErrors, mergeUniqueErrorsForFields, uniquenessFieldKeys]
);
// Improved revalidateRows function
const revalidateRows = useCallback(
async (
rowIndexes: number[],
updatedFields?: { [rowIndex: number]: string[] }
) => {
const uniqueFieldsToCheck = new Set<string>();
const fieldsMarked: Array<[number, string]> = [];
// Process all specified rows using a single state update to avoid race conditions
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 >= currentData.length) continue;
const row = currentData[rowIndex];
if (rowIndex < 0 || rowIndex >= data.length) continue;
const row = data[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;
updateValidatingCell(rowIndex, fieldKey, true);
fieldsMarked.push([rowIndex, fieldKey]);
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) {
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 {
@@ -544,40 +462,31 @@ 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));
}
},
[
fields,
scheduleUniqueValidation,
setValidationErrors,
uniquenessFieldKeys,
validateFieldFromHook,
updateValidatingCell
]
[data, fields, validateFieldFromHook, mergeUniqueErrorsForFields, uniquenessFieldKeys]
);
// Copy a cell value to all cells below it in the same column
const copyDown = useCallback(
(rowIndex: number, key: T) => {
const currentData = dataRef.current;
const sourceRow = currentData[rowIndex];
if (!sourceRow) return;
// Get the source value to copy
const sourceValue = data[rowIndex][key];
const sourceValue = sourceRow[key];
for (let i = rowIndex + 1; i < currentData.length; i++) {
// 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
updateRow(i, key, sourceValue);
}
},
[updateRow]
[data, updateRow]
);
return {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo, useRef, startTransition } from "react";
import { useState, useEffect, useCallback, useMemo, useRef } 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, isEmpty as isValueEmpty } from "./validationTypes";
import { Props, RowData } from "./validationTypes";
// Country normalization helper (common mappings) - function declaration for hoisting
function normalizeCountryCode(input: string): string | null {
@@ -145,7 +145,6 @@ 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>(
@@ -153,8 +152,7 @@ export const useValidationState = <T extends string>({
fields,
setData,
setValidationErrors,
validateFieldFromHook,
setValidatingCells
validateFieldFromHook
);
// Use UPC validation hook - MUST be initialized before template management
@@ -308,63 +306,148 @@ 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`);
const validationErrorsTemp = new Map<number, Record<string, any[]>>();
const mutatedRows: Array<[number, RowData<T>]> = [];
// 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 totalRows = data.length;
let currentIndex = 0;
let cancelled = false;
const cleanupCallbacks = new Set<() => void>();
// Initialize new data for any modifications
const newData = [...data];
const processRow = (rowIndex: number) => {
// 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 row = data[rowIndex];
if (!row) return;
const rowErrors: Record<string, any[]> = {};
// Skip if row is empty or undefined
if (!row) {
resolve();
return;
}
// Store field errors for this row
const fieldErrors: Record<string, any[]> = {};
let hasErrors = false;
// Check if price fields need formatting
const rowAsRecord = row as Record<string, any>;
let updatedRow: Record<string, any> | null = null;
let mSrpNeedsProcessing = false;
let costEachNeedsProcessing = false;
const ensureUpdatedRow = () => {
if (!updatedRow) {
updatedRow = { ...rowAsRecord };
if (
rowAsRecord.msrp &&
typeof rowAsRecord.msrp === "string" &&
(rowAsRecord.msrp.includes("$") ||
rowAsRecord.msrp.includes(","))
) {
mSrpNeedsProcessing = true;
}
return updatedRow;
};
if (typeof rowAsRecord.msrp === "string" && /[$,]/.test(rowAsRecord.msrp)) {
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) {
const msrpValue = rowAsRecord.msrp.replace(/[$,]/g, "");
const numValue = parseFloat(msrpValue);
ensureUpdatedRow().msrp = Number.isNaN(numValue) ? msrpValue : numValue.toFixed(2);
cleanedRow.msrp = !isNaN(numValue)
? numValue.toFixed(2)
: msrpValue;
}
if (typeof rowAsRecord.cost_each === "string" && /[$,]/.test(rowAsRecord.cost_each)) {
if (costEachNeedsProcessing) {
const costValue = rowAsRecord.cost_each.replace(/[$,]/g, "");
const numValue = parseFloat(costValue);
ensureUpdatedRow().cost_each = Number.isNaN(numValue) ? costValue : numValue.toFixed(2);
cleanedRow.cost_each = !isNaN(numValue)
? numValue.toFixed(2)
: costValue;
}
newData[rowIndex] = cleanedRow as RowData<T>;
}
// Validate required fields
for (const field of requiredFields) {
const key = String(field.key);
const value = rowAsRecord[key];
const value = row[key as keyof typeof row];
if (isValueEmpty(value)) {
rowErrors[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] = [
{
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",
@@ -374,18 +457,30 @@ 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 = rowAsRecord[key];
if (value === undefined || value === null || value === "") continue;
const value = row[key as keyof typeof row];
const regexValidation = field.validations?.find((v) => v.rule === "regex");
if (!regexValidation) continue;
// Skip empty values as they're handled by required validation
if (value === undefined || value === null || value === "") {
continue;
}
// Find regex validation
const regexValidation = field.validations?.find(
(v) => v.rule === "regex"
);
if (regexValidation) {
try {
const regex = new RegExp(regexValidation.value, regexValidation.flags);
// Check if value matches regex
const regex = new RegExp(
regexValidation.value,
regexValidation.flags
);
if (!regex.test(String(value))) {
rowErrors[key] = [
// Add regex validation error
fieldErrors[key] = [
{
message: regexValidation.errorMessage,
level: regexValidation.level || "error",
@@ -399,143 +494,84 @@ 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, rowErrors);
validationErrorsTemp.set(rowIndex, fieldErrors);
}
resolve();
})
);
}
// Wait for all row validations to complete
await Promise.all(batchPromises);
};
const finalize = () => {
if (cancelled) return;
const processAllBatches = async () => {
for (let batch = 0; batch < totalBatches; batch++) {
currentBatch = batch;
await processBatch();
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;
});
// 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));
}
}
// 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");
};
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();
// Start the validation process
processAllBatches();
};
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();
};
// Run the complete validation
runCompleteValidation();
}, [data, fields, setData, setValidationErrors, validateUniqueItemNumbers, upcValidation.initialValidationDone]);
// Targeted uniqueness revalidation: run only when item_number values change
useEffect(() => {
if (!data || data.length === 0) return;
const sig = data
.map((r) => String((r as Record<string, any>).item_number ?? ''))
.join('|');
// Build a simple signature of the item_number column
const sig = data.map((r) => String((r as Record<string, any>).item_number ?? '')).join('|');
if (lastItemNumberSigRef.current === sig) return;
lastItemNumberSigRef.current = sig;
pendingItemNumberValidationRef.current?.cancel();
let cancelled = false;
const currentData = data;
const runValidation = () => {
if (cancelled) return;
const uniqueMap = validateUniqueField(currentData, 'item_number');
// Compute unique errors for item_number only and merge
const uniqueMap = validateUniqueField(data, '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 = (currentData[rowIdx] as any)?.['item_number'];
const currentValue = (data[rowIdx] as any)?.['item_number'];
// Only apply uniqueness error when the value is non-empty
if (info && currentValue !== undefined && currentValue !== null && String(currentValue) !== '') {
existing['item_number'] = [
{
@@ -546,30 +582,25 @@ 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 = (currentData[rowIdx] as any)?.['item_number'];
const shouldRemoveUnique =
!rowsWithUnique.has(rowIdx) ||
currentValue === undefined ||
currentValue === null ||
String(currentValue) === '';
const currentValue = (data[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;
@@ -582,51 +613,6 @@ 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

View File

@@ -452,7 +452,7 @@ export function Import() {
return {
...field,
fieldType: {
type: "multi-select" as const,
type: "select" as const,
options: fieldOptions.taxCategories || [],
},
};
@@ -521,7 +521,46 @@ export function Import() {
}
};
const normalizeValue = (value: DataValue, fieldType: FieldType): string | string[] | boolean | null => {
const expandScientificNotation = (input: string): string => {
const trimmed = input.trim();
const match = trimmed.match(/^(-?\d+)(?:\.(\d+))?[eE]\+?(\d+)$/);
if (!match) {
return trimmed;
}
const [, integerPart, fractionPart = "", exponentRaw] = match;
const exponent = Number.parseInt(exponentRaw, 10);
const digits = `${integerPart}${fractionPart}`;
const decimalShift = exponent - fractionPart.length;
if (decimalShift >= 0) {
return `${digits}${"0".repeat(decimalShift)}`;
}
const splitIndex = digits.length + decimalShift;
if (splitIndex <= 0) {
const sign = digits.startsWith("-") ? "-" : "";
const unsignedDigits = sign ? digits.slice(1) : digits;
return `${sign}0.${"0".repeat(Math.abs(splitIndex))}${unsignedDigits}`;
}
const sign = digits.startsWith("-") ? "-" : "";
const unsignedDigits = sign ? digits.slice(1) : digits;
return `${sign}${unsignedDigits.slice(0, splitIndex)}.${unsignedDigits.slice(splitIndex)}`;
};
const normalizeUpcValue = (value: string): string => {
const expanded = expandScientificNotation(value);
const digitsOnly = expanded.replace(/[^0-9]/g, "");
return digitsOnly || expanded;
};
const normalizeValue = (
key: ImportFieldKey,
value: DataValue,
fieldType: FieldType
): string | string[] | boolean | null => {
if (value === undefined || value === null || value === "") {
return getDefaultValue(fieldType);
}
@@ -550,7 +589,13 @@ export function Import() {
return value;
}
return String(value);
const stringValue = String(value);
if (key === "upc") {
return normalizeUpcValue(stringValue);
}
return stringValue;
};
const handleData = async (data: ImportResult, _file: File) => {
@@ -559,7 +604,8 @@ export function Import() {
const formattedRows: NormalizedProduct[] = rows.map((row) => {
const baseValues = importFields.reduce((acc, field) => {
const rawRow = row as Record<string, DataValue>;
acc[field.key as ImportFieldKey] = normalizeValue(rawRow[field.key], field.fieldType);
const fieldKey = field.key as ImportFieldKey;
acc[fieldKey] = normalizeValue(fieldKey, rawRow[field.key], field.fieldType);
return acc;
}, {} as Record<ImportFieldKey, string | string[] | boolean | null>);