Refactor validation hooks into smaller files
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getApiUrl, RowData } from './useValidationState';
|
import { getApiUrl, RowData } from './validationTypes';
|
||||||
import { Fields } from '../../../types';
|
import { Fields } from '../../../types';
|
||||||
import { Meta } from '../types';
|
import { Meta } from '../types';
|
||||||
import { addErrorsAndRunHooks } from '../utils/dataMutations';
|
import { addErrorsAndRunHooks } from '../utils/dataMutations';
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import type { Field, Fields, RowHook, TableHook } from '../../../types';
|
||||||
|
import type { Meta } from '../types';
|
||||||
|
import { ErrorSources, ErrorType, ValidationError } from '../../../types';
|
||||||
|
import { RowData, InfoWithSource, isEmpty } from './validationTypes';
|
||||||
|
|
||||||
|
// Create a cache for validation results to avoid repeated validation of the same data
|
||||||
|
const validationResultCache = new Map();
|
||||||
|
|
||||||
|
// Add a function to clear cache for a specific field value
|
||||||
|
export const clearValidationCacheForField = (fieldKey: string) => {
|
||||||
|
// Look for entries that match this field key
|
||||||
|
validationResultCache.forEach((_, key) => {
|
||||||
|
if (key.startsWith(`${fieldKey}-`)) {
|
||||||
|
validationResultCache.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a special function to clear all uniqueness validation caches
|
||||||
|
export const clearAllUniquenessCaches = () => {
|
||||||
|
// Clear cache for common unique fields
|
||||||
|
['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'].forEach(fieldKey => {
|
||||||
|
clearValidationCacheForField(fieldKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also clear any cache entries that might involve uniqueness validation
|
||||||
|
validationResultCache.forEach((_, key) => {
|
||||||
|
if (key.includes('unique')) {
|
||||||
|
validationResultCache.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFieldValidation = <T extends string>(
|
||||||
|
fields: Fields<T>,
|
||||||
|
rowHook?: RowHook<T>,
|
||||||
|
tableHook?: TableHook<T>
|
||||||
|
) => {
|
||||||
|
// Validate a single field
|
||||||
|
const validateField = useCallback((
|
||||||
|
value: any,
|
||||||
|
field: Field<T>
|
||||||
|
): ValidationError[] => {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
|
||||||
|
if (!field.validations) return errors;
|
||||||
|
|
||||||
|
// Create a cache key using field key, value, and validation rules
|
||||||
|
const cacheKey = `${field.key}-${String(value)}-${JSON.stringify(field.validations)}`;
|
||||||
|
|
||||||
|
// Check cache first to avoid redundant validation
|
||||||
|
if (validationResultCache.has(cacheKey)) {
|
||||||
|
return validationResultCache.get(cacheKey) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
field.validations.forEach(validation => {
|
||||||
|
switch (validation.rule) {
|
||||||
|
case 'required':
|
||||||
|
// Use the shared isEmpty function
|
||||||
|
if (isEmpty(value)) {
|
||||||
|
errors.push({
|
||||||
|
message: validation.errorMessage || 'This field is required',
|
||||||
|
level: validation.level || 'error',
|
||||||
|
type: ErrorType.Required
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'unique':
|
||||||
|
// Unique validation happens at table level, not here
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'regex':
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
try {
|
||||||
|
const regex = new RegExp(validation.value, validation.flags);
|
||||||
|
if (!regex.test(String(value))) {
|
||||||
|
errors.push({
|
||||||
|
message: validation.errorMessage,
|
||||||
|
level: validation.level || 'error',
|
||||||
|
type: ErrorType.Regex
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Invalid regex in validation:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store results in cache to speed up future validations
|
||||||
|
validationResultCache.set(cacheKey, errors);
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Validate a single row
|
||||||
|
const validateRow = useCallback(async (
|
||||||
|
row: RowData<T>,
|
||||||
|
rowIndex: number,
|
||||||
|
allRows: RowData<T>[]
|
||||||
|
): Promise<Meta> => {
|
||||||
|
// Run field-level validations
|
||||||
|
const fieldErrors: Record<string, ValidationError[]> = {};
|
||||||
|
|
||||||
|
fields.forEach(field => {
|
||||||
|
const value = row[String(field.key) as keyof typeof row];
|
||||||
|
const errors = validateField(value, field as Field<T>);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
fieldErrors[String(field.key)] = errors;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Special validation for supplier and company fields - only apply if the field exists in fields
|
||||||
|
if (fields.some(field => String(field.key) === 'supplier') && isEmpty(row.supplier)) {
|
||||||
|
fieldErrors['supplier'] = [{
|
||||||
|
message: 'Supplier is required',
|
||||||
|
level: 'error',
|
||||||
|
type: ErrorType.Required
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.some(field => String(field.key) === 'company') && isEmpty(row.company)) {
|
||||||
|
fieldErrors['company'] = [{
|
||||||
|
message: 'Company is required',
|
||||||
|
level: 'error',
|
||||||
|
type: ErrorType.Required
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run row hook if provided
|
||||||
|
let rowHookResult: Meta = {
|
||||||
|
__index: row.__index || String(rowIndex)
|
||||||
|
};
|
||||||
|
if (rowHook) {
|
||||||
|
try {
|
||||||
|
// Call the row hook and extract only the __index property
|
||||||
|
const result = await rowHook(row, rowIndex, allRows);
|
||||||
|
rowHookResult.__index = result.__index || rowHookResult.__index;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in row hook:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We no longer need to merge errors since we're not storing them in the row data
|
||||||
|
// The calling code should handle storing errors in the validationErrors Map
|
||||||
|
|
||||||
|
return {
|
||||||
|
__index: row.__index || String(rowIndex)
|
||||||
|
};
|
||||||
|
}, [fields, validateField, rowHook]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
validateField,
|
||||||
|
validateRow,
|
||||||
|
clearValidationCacheForField,
|
||||||
|
clearAllUniquenessCaches
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { FilterState, RowData } from './validationTypes';
|
||||||
|
import type { Fields } from '../../../types';
|
||||||
|
import { ValidationError } from '../../../types';
|
||||||
|
|
||||||
|
export const useFilterManagement = <T extends string>(
|
||||||
|
data: RowData<T>[],
|
||||||
|
fields: Fields<T>,
|
||||||
|
validationErrors: Map<number, Record<string, ValidationError[]>>
|
||||||
|
) => {
|
||||||
|
// Filter state
|
||||||
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
|
searchText: "",
|
||||||
|
showErrorsOnly: false,
|
||||||
|
filterField: null,
|
||||||
|
filterValue: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter data based on current filter state
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
return data.filter((row, index) => {
|
||||||
|
// Filter by search text
|
||||||
|
if (filters.searchText) {
|
||||||
|
const searchLower = filters.searchText.toLowerCase();
|
||||||
|
const matchesSearch = fields.some((field) => {
|
||||||
|
const value = row[field.key as keyof typeof row];
|
||||||
|
if (value === undefined || value === null) return false;
|
||||||
|
return String(value).toLowerCase().includes(searchLower);
|
||||||
|
});
|
||||||
|
if (!matchesSearch) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by errors
|
||||||
|
if (filters.showErrorsOnly) {
|
||||||
|
const hasErrors =
|
||||||
|
validationErrors.has(index) &&
|
||||||
|
Object.keys(validationErrors.get(index) || {}).length > 0;
|
||||||
|
if (!hasErrors) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by field value
|
||||||
|
if (filters.filterField && filters.filterValue) {
|
||||||
|
const fieldValue = row[filters.filterField as keyof typeof row];
|
||||||
|
if (fieldValue === undefined) return false;
|
||||||
|
|
||||||
|
const valueStr = String(fieldValue).toLowerCase();
|
||||||
|
const filterStr = filters.filterValue.toLowerCase();
|
||||||
|
|
||||||
|
if (!valueStr.includes(filterStr)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [data, fields, filters, validationErrors]);
|
||||||
|
|
||||||
|
// Get filter fields
|
||||||
|
const filterFields = useMemo(() => {
|
||||||
|
return fields.map((field) => ({
|
||||||
|
key: String(field.key),
|
||||||
|
label: field.label,
|
||||||
|
}));
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
// Get filter values for the selected field
|
||||||
|
const filterValues = useMemo(() => {
|
||||||
|
if (!filters.filterField) return [];
|
||||||
|
|
||||||
|
// Get unique values for the selected field
|
||||||
|
const uniqueValues = new Set<string>();
|
||||||
|
|
||||||
|
data.forEach((row) => {
|
||||||
|
const value = row[filters.filterField as keyof typeof row];
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
uniqueValues.add(String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(uniqueValues).map((value) => ({
|
||||||
|
value,
|
||||||
|
label: value,
|
||||||
|
}));
|
||||||
|
}, [data, filters.filterField]);
|
||||||
|
|
||||||
|
// Update filters
|
||||||
|
const updateFilters = useCallback((newFilters: Partial<FilterState>) => {
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
...newFilters,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset filters
|
||||||
|
const resetFilters = useCallback(() => {
|
||||||
|
setFilters({
|
||||||
|
searchText: "",
|
||||||
|
showErrorsOnly: false,
|
||||||
|
filterField: null,
|
||||||
|
filterValue: null,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filters,
|
||||||
|
filteredData,
|
||||||
|
filterFields,
|
||||||
|
filterValues,
|
||||||
|
updateFilters,
|
||||||
|
resetFilters
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { RowData } from './validationTypes';
|
||||||
|
import type { Field, Fields } from '../../../types';
|
||||||
|
import { ErrorType, ValidationError } from '../../../types';
|
||||||
|
|
||||||
|
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[]
|
||||||
|
) => {
|
||||||
|
// Helper function to validate a field value
|
||||||
|
const fieldValidationHelper = useCallback(
|
||||||
|
(rowIndex: number, specificField?: string) => {
|
||||||
|
// Skip validation if row doesn't exist
|
||||||
|
if (rowIndex < 0 || rowIndex >= data.length) return;
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
const value = row[specificField as keyof typeof row];
|
||||||
|
|
||||||
|
// Use state setter instead of direct mutation
|
||||||
|
setValidationErrors((prev) => {
|
||||||
|
const newErrors = new Map(prev);
|
||||||
|
const existingErrors = { ...(newErrors.get(rowIndex) || {}) };
|
||||||
|
|
||||||
|
// 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 === "" ||
|
||||||
|
(Array.isArray(value) && value.length === 0) ||
|
||||||
|
(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) {
|
||||||
|
// 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) {
|
||||||
|
existingErrors[specificField] = errors;
|
||||||
|
} else {
|
||||||
|
delete existingErrors[specificField];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update validation errors map
|
||||||
|
if (Object.keys(existingErrors).length > 0) {
|
||||||
|
newErrors.set(rowIndex, existingErrors);
|
||||||
|
} else {
|
||||||
|
newErrors.delete(rowIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Validate all fields in the row
|
||||||
|
setValidationErrors((prev) => {
|
||||||
|
const newErrors = new Map(prev);
|
||||||
|
const rowErrors: Record<string, ValidationError[]> = {};
|
||||||
|
|
||||||
|
fields.forEach((field) => {
|
||||||
|
const fieldKey = String(field.key);
|
||||||
|
const value = row[fieldKey as keyof typeof row];
|
||||||
|
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
rowErrors[fieldKey] = errors;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update validation errors map
|
||||||
|
if (Object.keys(rowErrors).length > 0) {
|
||||||
|
newErrors.set(rowIndex, rowErrors);
|
||||||
|
} else {
|
||||||
|
newErrors.delete(rowIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[data, fields, validateFieldFromHook, setValidationErrors]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use validateRow as an alias for fieldValidationHelper for compatibility
|
||||||
|
const validateRow = fieldValidationHelper;
|
||||||
|
|
||||||
|
// Modified updateRow function that properly handles field-specific validation
|
||||||
|
const updateRow = useCallback(
|
||||||
|
(rowIndex: number, key: T, value: any) => {
|
||||||
|
// Process value before updating data
|
||||||
|
let processedValue = value;
|
||||||
|
|
||||||
|
// Strip dollar signs from price fields
|
||||||
|
if (
|
||||||
|
(key === "msrp" || key === "cost_each") &&
|
||||||
|
typeof value === "string"
|
||||||
|
) {
|
||||||
|
processedValue = value.replace(/[$,]/g, "");
|
||||||
|
|
||||||
|
// Also ensure it's a valid number
|
||||||
|
const numValue = parseFloat(processedValue);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
processedValue = numValue.toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
|
||||||
|
// Update the data immediately - this sets the value
|
||||||
|
setData((prevData) => {
|
||||||
|
const newData = [...prevData];
|
||||||
|
if (rowIndex >= 0 && rowIndex < newData.length) {
|
||||||
|
newData[rowIndex] = updatedRow;
|
||||||
|
}
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the field definition
|
||||||
|
const field = fields.find((f) => String(f.key) === key);
|
||||||
|
if (!field) return;
|
||||||
|
|
||||||
|
// CRITICAL FIX: Combine both validation operations into a single state update
|
||||||
|
// to prevent intermediate rendering that causes error icon flashing
|
||||||
|
setValidationErrors((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
const existingErrors = newMap.get(rowIndex) || {};
|
||||||
|
const newRowErrors = { ...existingErrors };
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 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 (nonRequiredErrors.length === 0) {
|
||||||
|
// If no other errors, delete the field's errors entirely
|
||||||
|
delete newRowErrors[key as string];
|
||||||
|
} else {
|
||||||
|
// Otherwise keep non-required errors
|
||||||
|
newRowErrors[key as string] = nonRequiredErrors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 if (!newRowErrors[key as string]) {
|
||||||
|
// If no errors found and no existing errors, ensure field is removed from errors
|
||||||
|
delete newRowErrors[key as string];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the map
|
||||||
|
if (Object.keys(newRowErrors).length > 0) {
|
||||||
|
newMap.set(rowIndex, newRowErrors);
|
||||||
|
} else {
|
||||||
|
newMap.delete(rowIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle simple secondary effects here
|
||||||
|
setTimeout(() => {
|
||||||
|
// Use __index to find the actual row in the full data array
|
||||||
|
const rowId = rowData.__index;
|
||||||
|
|
||||||
|
// Handle company change - clear line/subline
|
||||||
|
if (key === "company" && processedValue) {
|
||||||
|
// Clear any existing line/subline values
|
||||||
|
setData((prevData) => {
|
||||||
|
const newData = [...prevData];
|
||||||
|
const idx = newData.findIndex((item) => item.__index === rowId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
newData[idx] = {
|
||||||
|
...newData[idx],
|
||||||
|
line: undefined,
|
||||||
|
subline: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
newData[idx] = {
|
||||||
|
...newData[idx],
|
||||||
|
subline: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
},
|
||||||
|
[data, fields, validateFieldFromHook, setData, setValidationErrors]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Improved revalidateRows function
|
||||||
|
const revalidateRows = useCallback(
|
||||||
|
async (
|
||||||
|
rowIndexes: number[],
|
||||||
|
updatedFields?: { [rowIndex: number]: string[] }
|
||||||
|
) => {
|
||||||
|
// Process all specified rows using a single state update to avoid race conditions
|
||||||
|
setValidationErrors((prev) => {
|
||||||
|
const newErrors = new Map(prev);
|
||||||
|
|
||||||
|
// Process each row
|
||||||
|
for (const rowIndex of rowIndexes) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the row's errors
|
||||||
|
if (Object.keys(rowErrors).length > 0) {
|
||||||
|
newErrors.set(rowIndex, rowErrors);
|
||||||
|
} else {
|
||||||
|
newErrors.delete(rowIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[data, fields, validateFieldFromHook]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Copy a cell value to all cells below it in the same column
|
||||||
|
const copyDown = useCallback(
|
||||||
|
(rowIndex: number, key: T) => {
|
||||||
|
// Get the source value to copy
|
||||||
|
const sourceValue = data[rowIndex][key];
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[data, updateRow]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
validateRow,
|
||||||
|
updateRow,
|
||||||
|
revalidateRows,
|
||||||
|
copyDown
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Template, RowData, TemplateState, getApiUrl } from './validationTypes';
|
||||||
|
import { RowSelectionState } from '@tanstack/react-table';
|
||||||
|
import { ValidationError } from '../../../types';
|
||||||
|
|
||||||
|
export const useTemplateManagement = <T extends string>(
|
||||||
|
data: RowData<T>[],
|
||||||
|
setData: React.Dispatch<React.SetStateAction<RowData<T>[]>>,
|
||||||
|
rowSelection: RowSelectionState,
|
||||||
|
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>,
|
||||||
|
setRowValidationStatus: React.Dispatch<React.SetStateAction<Map<number, "pending" | "validating" | "validated" | "error">>>,
|
||||||
|
validateRow: (rowIndex: number, specificField?: string) => void,
|
||||||
|
isApplyingTemplateRef: React.MutableRefObject<boolean>
|
||||||
|
) => {
|
||||||
|
// Template state
|
||||||
|
const [templates, setTemplates] = useState<Template[]>([]);
|
||||||
|
const [isLoadingTemplates, setIsLoadingTemplates] = useState(true);
|
||||||
|
const [templateState, setTemplateState] = useState<TemplateState>({
|
||||||
|
selectedTemplateId: null,
|
||||||
|
showSaveTemplateDialog: false,
|
||||||
|
newTemplateName: "",
|
||||||
|
newTemplateType: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load templates
|
||||||
|
const loadTemplates = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setIsLoadingTemplates(true);
|
||||||
|
console.log("Fetching templates from:", `${getApiUrl()}/templates`);
|
||||||
|
const response = await fetch(`${getApiUrl()}/templates`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch templates");
|
||||||
|
const templateData = await response.json();
|
||||||
|
const validTemplates = templateData.filter(
|
||||||
|
(t: any) =>
|
||||||
|
t && typeof t === "object" && t.id && t.company && t.product_type
|
||||||
|
);
|
||||||
|
setTemplates(validTemplates);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching templates:", error);
|
||||||
|
toast.error("Failed to load templates");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingTemplates(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Refresh templates
|
||||||
|
const refreshTemplates = useCallback(() => {
|
||||||
|
loadTemplates();
|
||||||
|
}, [loadTemplates]);
|
||||||
|
|
||||||
|
// Save a new template
|
||||||
|
const saveTemplate = useCallback(
|
||||||
|
async (name: string, type: string) => {
|
||||||
|
try {
|
||||||
|
// Get selected rows
|
||||||
|
const selectedRowIndex = Number(Object.keys(rowSelection)[0]);
|
||||||
|
const selectedRow = data[selectedRowIndex];
|
||||||
|
|
||||||
|
if (!selectedRow) {
|
||||||
|
toast.error("Please select a row to create a template");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract data for template, removing metadata fields
|
||||||
|
const {
|
||||||
|
__index,
|
||||||
|
__template,
|
||||||
|
__original,
|
||||||
|
__corrected,
|
||||||
|
__changes,
|
||||||
|
...templateData
|
||||||
|
} = selectedRow as any;
|
||||||
|
|
||||||
|
// Clean numeric values (remove $ from price fields)
|
||||||
|
const cleanedData: Record<string, any> = {};
|
||||||
|
|
||||||
|
// Process each key-value pair
|
||||||
|
Object.entries(templateData).forEach(([key, value]) => {
|
||||||
|
// Handle numeric values with dollar signs
|
||||||
|
if (typeof value === "string" && value.includes("$")) {
|
||||||
|
cleanedData[key] = value.replace(/[$,\s]/g, "").trim();
|
||||||
|
}
|
||||||
|
// Handle array values (like categories or ship_restrictions)
|
||||||
|
else if (Array.isArray(value)) {
|
||||||
|
cleanedData[key] = value;
|
||||||
|
}
|
||||||
|
// Handle other values
|
||||||
|
else {
|
||||||
|
cleanedData[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send the template to the API
|
||||||
|
const response = await fetch(`${getApiUrl()}/templates`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...cleanedData,
|
||||||
|
company: name,
|
||||||
|
product_type: type,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(
|
||||||
|
errorData.error || errorData.details || "Failed to save template"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the new template from the response
|
||||||
|
const newTemplate = await response.json();
|
||||||
|
|
||||||
|
// Update the templates list with the new template
|
||||||
|
setTemplates((prev) => [...prev, newTemplate]);
|
||||||
|
|
||||||
|
// Update the row to show it's using this template
|
||||||
|
setData((prev) => {
|
||||||
|
const newData = [...prev];
|
||||||
|
if (newData[selectedRowIndex]) {
|
||||||
|
newData[selectedRowIndex] = {
|
||||||
|
...newData[selectedRowIndex],
|
||||||
|
__template: newTemplate.id.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(`Template "${name}" saved successfully`);
|
||||||
|
|
||||||
|
// Reset dialog state
|
||||||
|
setTemplateState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
showSaveTemplateDialog: false,
|
||||||
|
newTemplateName: "",
|
||||||
|
newTemplateType: "",
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving template:", error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Failed to save template"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[data, rowSelection, setData]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply template to rows - optimized version
|
||||||
|
const applyTemplate = useCallback(
|
||||||
|
(templateId: string, rowIndexes: number[]) => {
|
||||||
|
const template = templates.find((t) => t.id.toString() === templateId);
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
toast.error("Template not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Applying template ${templateId} to rows:`, rowIndexes);
|
||||||
|
|
||||||
|
// Validate row indexes
|
||||||
|
const validRowIndexes = rowIndexes.filter(
|
||||||
|
(index) => index >= 0 && index < data.length && Number.isInteger(index)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validRowIndexes.length === 0) {
|
||||||
|
toast.error("No valid rows to update");
|
||||||
|
console.error("Invalid row indexes:", rowIndexes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the template application flag
|
||||||
|
isApplyingTemplateRef.current = true;
|
||||||
|
|
||||||
|
// Save scroll position
|
||||||
|
const scrollPosition = {
|
||||||
|
left: window.scrollX,
|
||||||
|
top: window.scrollY,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a copy of data and process all rows at once to minimize state updates
|
||||||
|
const newData = [...data];
|
||||||
|
const batchErrors = new Map<number, Record<string, ValidationError[]>>();
|
||||||
|
const batchStatuses = new Map<
|
||||||
|
number,
|
||||||
|
"pending" | "validating" | "validated" | "error"
|
||||||
|
>();
|
||||||
|
|
||||||
|
// Extract template fields once outside the loop
|
||||||
|
const templateFields = Object.entries(template).filter(
|
||||||
|
([key]) =>
|
||||||
|
![
|
||||||
|
"id",
|
||||||
|
"__meta",
|
||||||
|
"__template",
|
||||||
|
"__original",
|
||||||
|
"__corrected",
|
||||||
|
"__changes",
|
||||||
|
].includes(key)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply template to each valid row
|
||||||
|
validRowIndexes.forEach((index) => {
|
||||||
|
// Create a new row with template values
|
||||||
|
const originalRow = newData[index];
|
||||||
|
const updatedRow = { ...originalRow } as Record<string, any>;
|
||||||
|
|
||||||
|
// Apply template fields (excluding metadata fields)
|
||||||
|
for (const [key, value] of templateFields) {
|
||||||
|
updatedRow[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the row as using this template
|
||||||
|
updatedRow.__template = templateId;
|
||||||
|
|
||||||
|
// Update the row in the data array
|
||||||
|
newData[index] = updatedRow as RowData<T>;
|
||||||
|
|
||||||
|
// Clear validation errors and mark as validated
|
||||||
|
batchErrors.set(index, {});
|
||||||
|
batchStatuses.set(index, "validated");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Perform a single update for all rows
|
||||||
|
setData(newData);
|
||||||
|
|
||||||
|
// Update all validation errors and statuses at once
|
||||||
|
setValidationErrors((prev) => {
|
||||||
|
const newErrors = new Map(prev);
|
||||||
|
for (const [rowIndex, errors] of batchErrors.entries()) {
|
||||||
|
newErrors.set(rowIndex, errors);
|
||||||
|
}
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
|
||||||
|
setRowValidationStatus((prev) => {
|
||||||
|
const newStatus = new Map(prev);
|
||||||
|
for (const [rowIndex, status] of batchStatuses.entries()) {
|
||||||
|
newStatus.set(rowIndex, status);
|
||||||
|
}
|
||||||
|
return newStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore scroll position
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.scrollTo(scrollPosition.left, scrollPosition.top);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show success toast
|
||||||
|
if (validRowIndexes.length === 1) {
|
||||||
|
toast.success("Template applied");
|
||||||
|
} else if (validRowIndexes.length > 1) {
|
||||||
|
toast.success(`Template applied to ${validRowIndexes.length} rows`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check which rows need UPC validation
|
||||||
|
const upcValidationRows = validRowIndexes.filter((rowIndex) => {
|
||||||
|
const row = newData[rowIndex];
|
||||||
|
return row && row.upc && row.supplier;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate UPCs for rows that have both UPC and supplier
|
||||||
|
if (upcValidationRows.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`Validating UPCs for ${upcValidationRows.length} rows after template application`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schedule UPC validation for the next tick to allow UI to update first
|
||||||
|
setTimeout(() => {
|
||||||
|
upcValidationRows.forEach((rowIndex) => {
|
||||||
|
const row = newData[rowIndex];
|
||||||
|
if (row && row.upc && row.supplier) {
|
||||||
|
validateRow(rowIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the template application flag
|
||||||
|
isApplyingTemplateRef.current = false;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
data,
|
||||||
|
templates,
|
||||||
|
setData,
|
||||||
|
setValidationErrors,
|
||||||
|
setRowValidationStatus,
|
||||||
|
validateRow,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply template to selected rows
|
||||||
|
const applyTemplateToSelected = useCallback(
|
||||||
|
(templateId: string) => {
|
||||||
|
if (!templateId) return;
|
||||||
|
|
||||||
|
// Update the selected template ID
|
||||||
|
setTemplateState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
selectedTemplateId: templateId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Get selected row keys (which may be UUIDs)
|
||||||
|
const selectedKeys = Object.entries(rowSelection)
|
||||||
|
.filter(([_, selected]) => selected === true)
|
||||||
|
.map(([key, _]) => key);
|
||||||
|
|
||||||
|
console.log("Selected row keys:", selectedKeys);
|
||||||
|
|
||||||
|
if (selectedKeys.length === 0) {
|
||||||
|
toast.error("No rows selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map UUID keys to array indices
|
||||||
|
const selectedIndexes = selectedKeys
|
||||||
|
.map((key) => {
|
||||||
|
// Find the matching row index in the data array
|
||||||
|
const index = data.findIndex(
|
||||||
|
(row) =>
|
||||||
|
(row.__index && row.__index === key) || // Match by __index
|
||||||
|
String(data.indexOf(row)) === key // Or by numeric index
|
||||||
|
);
|
||||||
|
return index;
|
||||||
|
})
|
||||||
|
.filter((index) => index !== -1); // Filter out any not found
|
||||||
|
|
||||||
|
console.log("Mapped row indices:", selectedIndexes);
|
||||||
|
|
||||||
|
if (selectedIndexes.length === 0) {
|
||||||
|
toast.error("Could not find selected rows");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply template to selected rows
|
||||||
|
applyTemplate(templateId, selectedIndexes);
|
||||||
|
},
|
||||||
|
[rowSelection, applyTemplate, setTemplateState, data]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
templates,
|
||||||
|
isLoadingTemplates,
|
||||||
|
templateState,
|
||||||
|
setTemplateState,
|
||||||
|
loadTemplates,
|
||||||
|
refreshTemplates,
|
||||||
|
saveTemplate,
|
||||||
|
applyTemplate,
|
||||||
|
applyTemplateToSelected
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { RowData } from './validationTypes';
|
||||||
|
import type { Fields } from '../../../types';
|
||||||
|
import { ErrorSources, ErrorType, ValidationError } from '../../../types';
|
||||||
|
|
||||||
|
export const useUniqueItemNumbersValidation = <T extends string>(
|
||||||
|
data: RowData<T>[],
|
||||||
|
fields: Fields<T>,
|
||||||
|
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>
|
||||||
|
) => {
|
||||||
|
// Update validateUniqueItemNumbers to also check for uniqueness of UPC/barcode
|
||||||
|
const validateUniqueItemNumbers = useCallback(async () => {
|
||||||
|
console.log("Validating unique fields");
|
||||||
|
|
||||||
|
// Skip if no data
|
||||||
|
if (!data.length) return;
|
||||||
|
|
||||||
|
// Track unique identifiers in maps
|
||||||
|
const uniqueFieldsMap = new Map<string, Map<string, number[]>>();
|
||||||
|
|
||||||
|
// Find fields that need uniqueness validation
|
||||||
|
const uniqueFields = fields
|
||||||
|
.filter((field) => field.validations?.some((v) => v.rule === "unique"))
|
||||||
|
.map((field) => String(field.key));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Found ${uniqueFields.length} fields requiring uniqueness validation:`,
|
||||||
|
uniqueFields
|
||||||
|
);
|
||||||
|
|
||||||
|
// Always check item_number uniqueness even if not explicitly defined
|
||||||
|
if (!uniqueFields.includes("item_number")) {
|
||||||
|
uniqueFields.push("item_number");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize maps for each unique field
|
||||||
|
uniqueFields.forEach((fieldKey) => {
|
||||||
|
uniqueFieldsMap.set(fieldKey, new Map<string, number[]>());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize batch updates
|
||||||
|
const errors = new Map<number, Record<string, ValidationError[]>>();
|
||||||
|
|
||||||
|
// Single pass through data to identify all unique values
|
||||||
|
data.forEach((row, index) => {
|
||||||
|
uniqueFields.forEach((fieldKey) => {
|
||||||
|
const value = row[fieldKey as keyof typeof row];
|
||||||
|
|
||||||
|
// Skip empty values
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueStr = String(value);
|
||||||
|
const fieldMap = uniqueFieldsMap.get(fieldKey);
|
||||||
|
|
||||||
|
if (fieldMap) {
|
||||||
|
// Get or initialize the array of indices for this value
|
||||||
|
const indices = fieldMap.get(valueStr) || [];
|
||||||
|
indices.push(index);
|
||||||
|
fieldMap.set(valueStr, indices);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process duplicates
|
||||||
|
uniqueFields.forEach((fieldKey) => {
|
||||||
|
const fieldMap = uniqueFieldsMap.get(fieldKey);
|
||||||
|
if (!fieldMap) return;
|
||||||
|
|
||||||
|
fieldMap.forEach((indices, value) => {
|
||||||
|
// Only process if there are duplicates
|
||||||
|
if (indices.length > 1) {
|
||||||
|
// Get the validation rule for this field
|
||||||
|
const field = fields.find((f) => String(f.key) === fieldKey);
|
||||||
|
const validationRule = field?.validations?.find(
|
||||||
|
(v) => v.rule === "unique"
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorObj = {
|
||||||
|
message:
|
||||||
|
validationRule?.errorMessage || `Duplicate ${fieldKey}: ${value}`,
|
||||||
|
level: validationRule?.level || ("error" as "error"),
|
||||||
|
source: ErrorSources.Table,
|
||||||
|
type: ErrorType.Unique,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add error to each row with this value
|
||||||
|
indices.forEach((rowIndex) => {
|
||||||
|
const rowErrors = errors.get(rowIndex) || {};
|
||||||
|
rowErrors[fieldKey] = [errorObj];
|
||||||
|
errors.set(rowIndex, rowErrors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply batch updates only if we have errors to report
|
||||||
|
if (errors.size > 0) {
|
||||||
|
// OPTIMIZATION: Check if we actually have new errors before updating state
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
// We'll update errors with a single batch operation
|
||||||
|
setValidationErrors((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
|
||||||
|
// Check each row for changes
|
||||||
|
errors.forEach((rowErrors, rowIndex) => {
|
||||||
|
const existingErrors = newMap.get(rowIndex) || {};
|
||||||
|
const updatedErrors = { ...existingErrors };
|
||||||
|
let rowHasChanges = false;
|
||||||
|
|
||||||
|
// Check each field for changes
|
||||||
|
Object.entries(rowErrors).forEach(([fieldKey, fieldErrors]) => {
|
||||||
|
// Compare with existing errors
|
||||||
|
const existingFieldErrors = existingErrors[fieldKey];
|
||||||
|
|
||||||
|
if (
|
||||||
|
!existingFieldErrors ||
|
||||||
|
existingFieldErrors.length !== fieldErrors.length ||
|
||||||
|
!existingFieldErrors.every(
|
||||||
|
(err, idx) =>
|
||||||
|
err.message === fieldErrors[idx].message &&
|
||||||
|
err.type === fieldErrors[idx].type
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// We have a change
|
||||||
|
updatedErrors[fieldKey] = fieldErrors;
|
||||||
|
rowHasChanges = true;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only update if we have changes
|
||||||
|
if (rowHasChanges) {
|
||||||
|
newMap.set(rowIndex, updatedErrors);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only return a new map if we have changes
|
||||||
|
return hasChanges ? newMap : prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Uniqueness validation complete");
|
||||||
|
}, [data, fields, setValidationErrors]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
validateUniqueItemNumbers
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import type { Fields } from '../../../types';
|
||||||
|
import { ErrorSources, ErrorType } from '../../../types';
|
||||||
|
import { RowData, InfoWithSource, isEmpty } from './validationTypes';
|
||||||
|
import { clearValidationCacheForField } from './useFieldValidation';
|
||||||
|
|
||||||
|
export const useUniqueValidation = <T extends string>(
|
||||||
|
fields: Fields<T>
|
||||||
|
) => {
|
||||||
|
// Additional function to explicitly validate uniqueness for specified fields
|
||||||
|
const validateUniqueField = useCallback((data: RowData<T>[], fieldKey: string) => {
|
||||||
|
// Field keys that need special handling for uniqueness
|
||||||
|
const uniquenessFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
|
||||||
|
|
||||||
|
// If the field doesn't need uniqueness validation, return empty errors
|
||||||
|
if (!uniquenessFields.includes(fieldKey)) {
|
||||||
|
const field = fields.find(f => String(f.key) === fieldKey);
|
||||||
|
if (!field || !field.validations?.some(v => v.rule === 'unique')) {
|
||||||
|
return new Map<number, Record<string, InfoWithSource>>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create map to track errors
|
||||||
|
const uniqueErrors = new Map<number, Record<string, InfoWithSource>>();
|
||||||
|
|
||||||
|
// Find the field definition
|
||||||
|
const field = fields.find(f => String(f.key) === fieldKey);
|
||||||
|
if (!field) return uniqueErrors;
|
||||||
|
|
||||||
|
// Get validation properties
|
||||||
|
const validation = field.validations?.find(v => v.rule === 'unique');
|
||||||
|
const allowEmpty = validation?.allowEmpty ?? false;
|
||||||
|
const errorMessage = validation?.errorMessage || `${field.label} must be unique`;
|
||||||
|
const level = validation?.level || 'error';
|
||||||
|
|
||||||
|
// Track values for uniqueness check
|
||||||
|
const valueMap = new Map<string, number[]>();
|
||||||
|
|
||||||
|
// Build value map
|
||||||
|
data.forEach((row, rowIndex) => {
|
||||||
|
const value = String(row[fieldKey as keyof typeof row] || '');
|
||||||
|
|
||||||
|
// Skip empty values if allowed
|
||||||
|
if (allowEmpty && isEmpty(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valueMap.has(value)) {
|
||||||
|
valueMap.set(value, [rowIndex]);
|
||||||
|
} else {
|
||||||
|
valueMap.get(value)?.push(rowIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add errors for duplicate values
|
||||||
|
valueMap.forEach((rowIndexes, value) => {
|
||||||
|
if (rowIndexes.length > 1) {
|
||||||
|
// Skip empty values
|
||||||
|
if (!value || value.trim() === '') return;
|
||||||
|
|
||||||
|
// Add error to all duplicate rows
|
||||||
|
rowIndexes.forEach(rowIndex => {
|
||||||
|
// Create errors object if needed
|
||||||
|
if (!uniqueErrors.has(rowIndex)) {
|
||||||
|
uniqueErrors.set(rowIndex, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add error for this field
|
||||||
|
uniqueErrors.get(rowIndex)![fieldKey] = {
|
||||||
|
message: errorMessage,
|
||||||
|
level: level as 'info' | 'warning' | 'error',
|
||||||
|
source: ErrorSources.Table,
|
||||||
|
type: ErrorType.Unique
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return uniqueErrors;
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
// Validate uniqueness for multiple fields
|
||||||
|
const validateUniqueFields = useCallback((data: RowData<T>[], fieldKeys: string[]) => {
|
||||||
|
// Process each field and merge results
|
||||||
|
const allErrors = new Map<number, Record<string, InfoWithSource>>();
|
||||||
|
|
||||||
|
fieldKeys.forEach(fieldKey => {
|
||||||
|
const fieldErrors = validateUniqueField(data, fieldKey);
|
||||||
|
|
||||||
|
// Merge errors
|
||||||
|
fieldErrors.forEach((errors, rowIdx) => {
|
||||||
|
if (!allErrors.has(rowIdx)) {
|
||||||
|
allErrors.set(rowIdx, {});
|
||||||
|
}
|
||||||
|
Object.assign(allErrors.get(rowIdx)!, errors);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return allErrors;
|
||||||
|
}, [validateUniqueField]);
|
||||||
|
|
||||||
|
// Run complete validation for uniqueness
|
||||||
|
const validateAllUniqueFields = useCallback((data: RowData<T>[]) => {
|
||||||
|
// Get fields requiring uniqueness validation
|
||||||
|
const uniqueFields = fields
|
||||||
|
.filter(field => field.validations?.some(v => v.rule === 'unique'))
|
||||||
|
.map(field => String(field.key));
|
||||||
|
|
||||||
|
// Also add standard unique fields that might not be explicitly marked as unique
|
||||||
|
const standardUniqueFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
|
||||||
|
|
||||||
|
// Combine all fields that need uniqueness validation
|
||||||
|
const allUniqueFieldKeys = [...new Set([
|
||||||
|
...uniqueFields,
|
||||||
|
...standardUniqueFields
|
||||||
|
])];
|
||||||
|
|
||||||
|
// Filter to only fields that exist in the data
|
||||||
|
const existingFields = allUniqueFieldKeys.filter(fieldKey =>
|
||||||
|
data.some(row => fieldKey in row)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate all fields at once
|
||||||
|
return validateUniqueFields(data, existingFields);
|
||||||
|
}, [fields, validateUniqueFields]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
validateUniqueField,
|
||||||
|
validateUniqueFields,
|
||||||
|
validateAllUniqueFields
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,248 +1,24 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import type { Field, Fields, RowHook, TableHook } from '../../../types'
|
import type { Field, Fields, RowHook, TableHook } from '../../../types'
|
||||||
import type { Meta } from '../types'
|
import { ErrorSources } from '../../../types'
|
||||||
import { ErrorSources, ErrorType, ValidationError } from '../../../types'
|
import { RowData, InfoWithSource } from './validationTypes'
|
||||||
import { RowData } from './useValidationState'
|
import { useFieldValidation, clearValidationCacheForField, clearAllUniquenessCaches } from './useFieldValidation'
|
||||||
|
import { useUniqueValidation } from './useUniqueValidation'
|
||||||
// Define InfoWithSource to match the expected structure
|
|
||||||
// Make sure source is required (not optional)
|
|
||||||
export interface InfoWithSource {
|
|
||||||
message: string;
|
|
||||||
level: 'info' | 'warning' | 'error';
|
|
||||||
source: ErrorSources;
|
|
||||||
type: ErrorType;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared utility function for checking empty values - defined once to avoid duplication
|
|
||||||
const isEmpty = (value: any): boolean =>
|
|
||||||
value === undefined ||
|
|
||||||
value === null ||
|
|
||||||
value === '' ||
|
|
||||||
(Array.isArray(value) && value.length === 0) ||
|
|
||||||
(typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0);
|
|
||||||
|
|
||||||
// Create a cache for validation results to avoid repeated validation of the same data
|
|
||||||
const validationResultCache = new Map();
|
|
||||||
|
|
||||||
// Add a function to clear cache for a specific field value
|
|
||||||
export const clearValidationCacheForField = (fieldKey: string) => {
|
|
||||||
// Look for entries that match this field key
|
|
||||||
validationResultCache.forEach((_, key) => {
|
|
||||||
if (key.startsWith(`${fieldKey}-`)) {
|
|
||||||
validationResultCache.delete(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add a special function to clear all uniqueness validation caches
|
|
||||||
export const clearAllUniquenessCaches = () => {
|
|
||||||
// Clear cache for common unique fields
|
|
||||||
['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'].forEach(fieldKey => {
|
|
||||||
clearValidationCacheForField(fieldKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also clear any cache entries that might involve uniqueness validation
|
|
||||||
validationResultCache.forEach((_, key) => {
|
|
||||||
if (key.includes('unique')) {
|
|
||||||
validationResultCache.delete(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Main validation hook that brings together field and uniqueness validation
|
||||||
export const useValidation = <T extends string>(
|
export const useValidation = <T extends string>(
|
||||||
fields: Fields<T>,
|
fields: Fields<T>,
|
||||||
rowHook?: RowHook<T>,
|
rowHook?: RowHook<T>,
|
||||||
tableHook?: TableHook<T>
|
tableHook?: TableHook<T>
|
||||||
) => {
|
) => {
|
||||||
// Validate a single field
|
// Use the field validation hook
|
||||||
const validateField = useCallback((
|
const { validateField, validateRow } = useFieldValidation(fields, rowHook, tableHook);
|
||||||
value: any,
|
|
||||||
field: Field<T>
|
|
||||||
): ValidationError[] => {
|
|
||||||
const errors: ValidationError[] = []
|
|
||||||
|
|
||||||
if (!field.validations) return errors
|
// Use the uniqueness validation hook
|
||||||
|
const {
|
||||||
// Create a cache key using field key, value, and validation rules
|
validateUniqueField,
|
||||||
const cacheKey = `${field.key}-${String(value)}-${JSON.stringify(field.validations)}`;
|
validateAllUniqueFields
|
||||||
|
} = useUniqueValidation(fields);
|
||||||
// Check cache first to avoid redundant validation
|
|
||||||
if (validationResultCache.has(cacheKey)) {
|
|
||||||
return validationResultCache.get(cacheKey) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
field.validations.forEach(validation => {
|
|
||||||
switch (validation.rule) {
|
|
||||||
case 'required':
|
|
||||||
// Use the shared isEmpty function
|
|
||||||
if (isEmpty(value)) {
|
|
||||||
errors.push({
|
|
||||||
message: validation.errorMessage || 'This field is required',
|
|
||||||
level: validation.level || 'error',
|
|
||||||
type: ErrorType.Required
|
|
||||||
})
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'unique':
|
|
||||||
// Unique validation happens at table level, not here
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'regex':
|
|
||||||
if (value !== undefined && value !== null && value !== '') {
|
|
||||||
try {
|
|
||||||
const regex = new RegExp(validation.value, validation.flags)
|
|
||||||
if (!regex.test(String(value))) {
|
|
||||||
errors.push({
|
|
||||||
message: validation.errorMessage,
|
|
||||||
level: validation.level || 'error',
|
|
||||||
type: ErrorType.Regex
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Invalid regex in validation:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Store results in cache to speed up future validations
|
|
||||||
validationResultCache.set(cacheKey, errors);
|
|
||||||
|
|
||||||
return errors
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Validate a single row
|
|
||||||
const validateRow = useCallback(async (
|
|
||||||
row: RowData<T>,
|
|
||||||
rowIndex: number,
|
|
||||||
allRows: RowData<T>[]
|
|
||||||
): Promise<Meta> => {
|
|
||||||
// Run field-level validations
|
|
||||||
const fieldErrors: Record<string, ValidationError[]> = {}
|
|
||||||
|
|
||||||
// Use the shared isEmpty function
|
|
||||||
|
|
||||||
fields.forEach(field => {
|
|
||||||
const value = row[String(field.key) as keyof typeof row]
|
|
||||||
const errors = validateField(value, field as Field<T>)
|
|
||||||
|
|
||||||
if (errors.length > 0) {
|
|
||||||
fieldErrors[String(field.key)] = errors
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Special validation for supplier and company fields - only apply if the field exists in fields
|
|
||||||
if (fields.some(field => String(field.key) === 'supplier') && isEmpty(row.supplier)) {
|
|
||||||
fieldErrors['supplier'] = [{
|
|
||||||
message: 'Supplier is required',
|
|
||||||
level: 'error',
|
|
||||||
type: ErrorType.Required
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fields.some(field => String(field.key) === 'company') && isEmpty(row.company)) {
|
|
||||||
fieldErrors['company'] = [{
|
|
||||||
message: 'Company is required',
|
|
||||||
level: 'error',
|
|
||||||
type: ErrorType.Required
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run row hook if provided
|
|
||||||
let rowHookResult: Meta = {
|
|
||||||
__index: row.__index || String(rowIndex)
|
|
||||||
}
|
|
||||||
if (rowHook) {
|
|
||||||
try {
|
|
||||||
// Call the row hook and extract only the __index property
|
|
||||||
const result = await rowHook(row, rowIndex, allRows);
|
|
||||||
rowHookResult.__index = result.__index || rowHookResult.__index;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in row hook:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We no longer need to merge errors since we're not storing them in the row data
|
|
||||||
// The calling code should handle storing errors in the validationErrors Map
|
|
||||||
|
|
||||||
return {
|
|
||||||
__index: row.__index || String(rowIndex)
|
|
||||||
}
|
|
||||||
}, [fields, validateField, rowHook])
|
|
||||||
|
|
||||||
// Additional function to explicitly validate uniqueness for specified fields
|
|
||||||
const validateUniqueField = useCallback((data: RowData<T>[], fieldKey: string) => {
|
|
||||||
// Field keys that need special handling for uniqueness
|
|
||||||
const uniquenessFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
|
|
||||||
|
|
||||||
// If the field doesn't need uniqueness validation, return empty errors
|
|
||||||
if (!uniquenessFields.includes(fieldKey)) {
|
|
||||||
const field = fields.find(f => String(f.key) === fieldKey);
|
|
||||||
if (!field || !field.validations?.some(v => v.rule === 'unique')) {
|
|
||||||
return new Map<number, Record<string, InfoWithSource>>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create map to track errors
|
|
||||||
const uniqueErrors = new Map<number, Record<string, InfoWithSource>>();
|
|
||||||
|
|
||||||
// Find the field definition
|
|
||||||
const field = fields.find(f => String(f.key) === fieldKey);
|
|
||||||
if (!field) return uniqueErrors;
|
|
||||||
|
|
||||||
// Get validation properties
|
|
||||||
const validation = field.validations?.find(v => v.rule === 'unique');
|
|
||||||
const allowEmpty = validation?.allowEmpty ?? false;
|
|
||||||
const errorMessage = validation?.errorMessage || `${field.label} must be unique`;
|
|
||||||
const level = validation?.level || 'error';
|
|
||||||
|
|
||||||
// Track values for uniqueness check
|
|
||||||
const valueMap = new Map<string, number[]>();
|
|
||||||
|
|
||||||
// Build value map
|
|
||||||
data.forEach((row, rowIndex) => {
|
|
||||||
const value = String(row[fieldKey as keyof typeof row] || '');
|
|
||||||
|
|
||||||
// Skip empty values if allowed
|
|
||||||
if (allowEmpty && isEmpty(value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!valueMap.has(value)) {
|
|
||||||
valueMap.set(value, [rowIndex]);
|
|
||||||
} else {
|
|
||||||
valueMap.get(value)?.push(rowIndex);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add errors for duplicate values
|
|
||||||
valueMap.forEach((rowIndexes, value) => {
|
|
||||||
if (rowIndexes.length > 1) {
|
|
||||||
// Skip empty values
|
|
||||||
if (!value || value.trim() === '') return;
|
|
||||||
|
|
||||||
// Add error to all duplicate rows
|
|
||||||
rowIndexes.forEach(rowIndex => {
|
|
||||||
// Create errors object if needed
|
|
||||||
if (!uniqueErrors.has(rowIndex)) {
|
|
||||||
uniqueErrors.set(rowIndex, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add error for this field
|
|
||||||
uniqueErrors.get(rowIndex)![fieldKey] = {
|
|
||||||
message: errorMessage,
|
|
||||||
level: level as 'info' | 'warning' | 'error',
|
|
||||||
source: ErrorSources.Table,
|
|
||||||
type: ErrorType.Unique
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return uniqueErrors;
|
|
||||||
}, [fields]);
|
|
||||||
|
|
||||||
// Run complete validation
|
// Run complete validation
|
||||||
const validateData = useCallback(async (data: RowData<T>[], fieldToUpdate?: { rowIndex: number, fieldKey: string }) => {
|
const validateData = useCallback(async (data: RowData<T>[], fieldToUpdate?: { rowIndex: number, fieldKey: string }) => {
|
||||||
@@ -341,9 +117,6 @@ export const useValidation = <T extends string>(
|
|||||||
// Full validation - all fields for all rows
|
// Full validation - all fields for all rows
|
||||||
console.log('Running full validation for all fields and rows');
|
console.log('Running full validation for all fields and rows');
|
||||||
|
|
||||||
// Clear validation cache for full validation
|
|
||||||
validationResultCache.clear();
|
|
||||||
|
|
||||||
// Process each row for field-level validations
|
// Process each row for field-level validations
|
||||||
for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
|
for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
|
||||||
const row = data[rowIndex];
|
const row = data[rowIndex];
|
||||||
@@ -371,38 +144,15 @@ export const useValidation = <T extends string>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get fields requiring uniqueness validation
|
// Validate all unique fields
|
||||||
const uniqueFields = fields.filter(field =>
|
const uniqueErrors = validateAllUniqueFields(data);
|
||||||
field.validations?.some(v => v.rule === 'unique')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Also add standard unique fields that might not be explicitly marked as unique
|
// Merge in unique errors
|
||||||
const standardUniqueFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
|
uniqueErrors.forEach((errors, rowIdx) => {
|
||||||
|
if (!validationErrors.has(rowIdx)) {
|
||||||
// Combine all fields that need uniqueness validation
|
validationErrors.set(rowIdx, {});
|
||||||
const allUniqueFieldKeys = new Set([
|
}
|
||||||
...uniqueFields.map(field => String(field.key)),
|
Object.assign(validationErrors.get(rowIdx)!, errors);
|
||||||
...standardUniqueFields
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Log uniqueness validation fields
|
|
||||||
console.log('Validating unique fields:', Array.from(allUniqueFieldKeys));
|
|
||||||
|
|
||||||
// Run uniqueness validation for each unique field
|
|
||||||
allUniqueFieldKeys.forEach(fieldKey => {
|
|
||||||
// Check if this field exists in the data
|
|
||||||
const hasField = data.some(row => fieldKey in row);
|
|
||||||
if (!hasField) return;
|
|
||||||
|
|
||||||
const uniqueErrors = validateUniqueField(data, fieldKey);
|
|
||||||
|
|
||||||
// Add unique errors to validation errors
|
|
||||||
uniqueErrors.forEach((errors, rowIdx) => {
|
|
||||||
if (!validationErrors.has(rowIdx)) {
|
|
||||||
validationErrors.set(rowIdx, {});
|
|
||||||
}
|
|
||||||
Object.assign(validationErrors.get(rowIdx)!, errors);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Uniqueness validation complete');
|
console.log('Uniqueness validation complete');
|
||||||
@@ -412,7 +162,7 @@ export const useValidation = <T extends string>(
|
|||||||
data,
|
data,
|
||||||
validationErrors
|
validationErrors
|
||||||
};
|
};
|
||||||
}, [fields, validateField, validateUniqueField]);
|
}, [fields, validateField, validateUniqueField, validateAllUniqueFields]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validateData,
|
validateData,
|
||||||
@@ -421,5 +171,5 @@ export const useValidation = <T extends string>(
|
|||||||
validateUniqueField,
|
validateUniqueField,
|
||||||
clearValidationCacheForField,
|
clearValidationCacheForField,
|
||||||
clearAllUniquenessCaches
|
clearAllUniquenessCaches
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
|||||||
|
import type { Data, Field } from "../../../types";
|
||||||
|
import { ErrorSources, ErrorType, ValidationError } from "../../../types";
|
||||||
|
import config from "@/config";
|
||||||
|
import { RowSelectionState } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
// Define the Props interface for ValidationStepNew
|
||||||
|
export interface Props<T extends string> {
|
||||||
|
initialData: RowData<T>[];
|
||||||
|
file?: File;
|
||||||
|
onBack?: () => void;
|
||||||
|
onNext?: (data: RowData<T>[]) => void;
|
||||||
|
isFromScratch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extended Data type with meta information
|
||||||
|
export type RowData<T extends string> = Data<T> & {
|
||||||
|
__index?: string;
|
||||||
|
__template?: string;
|
||||||
|
__original?: Record<string, any>;
|
||||||
|
__corrected?: Record<string, any>;
|
||||||
|
__changes?: Record<string, boolean>;
|
||||||
|
upc?: string;
|
||||||
|
barcode?: string;
|
||||||
|
supplier?: string;
|
||||||
|
company?: string;
|
||||||
|
item_number?: string;
|
||||||
|
[key: string]: any; // Allow any string key for dynamic fields
|
||||||
|
};
|
||||||
|
|
||||||
|
// Template interface
|
||||||
|
export interface Template {
|
||||||
|
id: number;
|
||||||
|
company: string;
|
||||||
|
product_type: string;
|
||||||
|
[key: string]: string | number | boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Props for the useValidationState hook
|
||||||
|
export interface ValidationStateProps<T extends string> extends Props<T> {}
|
||||||
|
|
||||||
|
// Interface for validation results
|
||||||
|
export interface ValidationResult {
|
||||||
|
error?: boolean;
|
||||||
|
message?: string;
|
||||||
|
data?: Record<string, any>;
|
||||||
|
type?: ErrorType;
|
||||||
|
source?: ErrorSources;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter state interface
|
||||||
|
export interface FilterState {
|
||||||
|
searchText: string;
|
||||||
|
showErrorsOnly: boolean;
|
||||||
|
filterField: string | null;
|
||||||
|
filterValue: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI validation state interface for useUpcValidation
|
||||||
|
export interface ValidationState {
|
||||||
|
validatingCells: Set<string>; // Using rowIndex-fieldKey as identifier
|
||||||
|
itemNumbers: Map<number, string>; // Using rowIndex as key
|
||||||
|
validatingRows: Set<number>; // Rows currently being validated
|
||||||
|
activeValidations: Set<string>; // Active validations
|
||||||
|
}
|
||||||
|
|
||||||
|
// InfoWithSource interface for validation errors
|
||||||
|
export interface InfoWithSource {
|
||||||
|
message: string;
|
||||||
|
level: 'info' | 'warning' | 'error';
|
||||||
|
source: ErrorSources;
|
||||||
|
type: ErrorType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template state interface
|
||||||
|
export interface TemplateState {
|
||||||
|
selectedTemplateId: string | null;
|
||||||
|
showSaveTemplateDialog: boolean;
|
||||||
|
newTemplateName: string;
|
||||||
|
newTemplateType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add config at the top of the file
|
||||||
|
// Import the config or access it through window
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
config?: {
|
||||||
|
apiUrl: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a helper to get API URL consistently
|
||||||
|
export const getApiUrl = () => config.apiUrl;
|
||||||
|
|
||||||
|
// Shared utility function for checking empty values
|
||||||
|
export const isEmpty = (value: any): boolean =>
|
||||||
|
value === undefined ||
|
||||||
|
value === null ||
|
||||||
|
value === '' ||
|
||||||
|
(Array.isArray(value) && value.length === 0) ||
|
||||||
|
(typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0);
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { ErrorType } from '../types/index'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts an InfoWithSource or similar error object to our Error type
|
|
||||||
* @param error The error object to convert
|
|
||||||
* @returns Our standardized Error object
|
|
||||||
*/
|
|
||||||
export const convertToError = (error: any): ErrorType => {
|
|
||||||
return {
|
|
||||||
message: typeof error.message === 'string' ? error.message : String(error.message || ''),
|
|
||||||
level: error.level || 'error',
|
|
||||||
source: error.source || 'row',
|
|
||||||
type: error.type || 'custom'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely convert an error or array of errors to our Error[] format
|
|
||||||
* @param errors The error or array of errors to convert
|
|
||||||
* @returns Array of our Error objects
|
|
||||||
*/
|
|
||||||
export const convertToErrorArray = (errors: any): ErrorType[] => {
|
|
||||||
if (Array.isArray(errors)) {
|
|
||||||
return errors.map(convertToError)
|
|
||||||
}
|
|
||||||
return [convertToError(errors)]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a record of errors to our standardized format
|
|
||||||
* @param errorRecord Record with string keys and error values
|
|
||||||
* @returns Standardized error record
|
|
||||||
*/
|
|
||||||
export const convertErrorRecord = (errorRecord: Record<string, any>): Record<string, ErrorType[]> => {
|
|
||||||
const result: Record<string, ErrorType[]> = {}
|
|
||||||
|
|
||||||
Object.entries(errorRecord).forEach(([key, errors]) => {
|
|
||||||
result[key] = convertToErrorArray(errors)
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder for validating UPC codes
|
|
||||||
* @param upcValue UPC value to validate
|
|
||||||
* @returns Validation result
|
|
||||||
*/
|
|
||||||
export const validateUpc = async (upcValue: string): Promise<any> => {
|
|
||||||
// Basic validation - UPC should be 12-14 digits
|
|
||||||
if (!/^\d{12,14}$/.test(upcValue)) {
|
|
||||||
toast.error('Invalid UPC format. UPC should be 12-14 digits.')
|
|
||||||
return {
|
|
||||||
error: true,
|
|
||||||
message: 'Invalid UPC format'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// In a real implementation, call an API to validate the UPC
|
|
||||||
// For now, just return a successful result
|
|
||||||
return {
|
|
||||||
error: false,
|
|
||||||
data: {
|
|
||||||
// Mock data that would be returned from the API
|
|
||||||
item_number: `ITEM-${upcValue.substring(0, 6)}`,
|
|
||||||
sku: `SKU-${upcValue.substring(0, 4)}`,
|
|
||||||
description: `Sample Product ${upcValue.substring(0, 4)}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates an item number for a UPC
|
|
||||||
* @param upcValue UPC value
|
|
||||||
* @returns Generated item number
|
|
||||||
*/
|
|
||||||
export const generateItemNumber = (upcValue: string): string => {
|
|
||||||
// Simple item number generation logic
|
|
||||||
return `ITEM-${upcValue.substring(0, 6)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder for handling UPC validation process
|
|
||||||
* @param upcValue UPC value to validate
|
|
||||||
* @param rowIndex Row index being validated
|
|
||||||
* @param updateRow Function to update row data
|
|
||||||
*/
|
|
||||||
export const handleUpcValidation = async (
|
|
||||||
upcValue: string,
|
|
||||||
rowIndex: number,
|
|
||||||
updateRow: (rowIndex: number, key: string, value: any) => void
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// Validate the UPC
|
|
||||||
const result = await validateUpc(upcValue)
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
toast.error(result.message || 'UPC validation failed')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update row with the validation result data
|
|
||||||
if (result.data) {
|
|
||||||
// Update each field returned from the API
|
|
||||||
Object.entries(result.data).forEach(([key, value]) => {
|
|
||||||
updateRow(rowIndex, key, value)
|
|
||||||
})
|
|
||||||
|
|
||||||
toast.success('UPC validated successfully')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error validating UPC:', error)
|
|
||||||
toast.error('Failed to validate UPC')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* Helper functions for validation that ensure proper error objects
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Create a standard error object
|
|
||||||
export const createError = (message, level = 'error', source = 'row') => {
|
|
||||||
return { message, level, source };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert any error to standard format
|
|
||||||
export const convertError = (error) => {
|
|
||||||
if (!error) return createError('Unknown error');
|
|
||||||
|
|
||||||
if (typeof error === 'string') {
|
|
||||||
return createError(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: error.message || 'Unknown error',
|
|
||||||
level: error.level || 'error',
|
|
||||||
source: error.source || 'row'
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert array of errors or single error to array
|
|
||||||
export const convertToErrorArray = (errors) => {
|
|
||||||
if (Array.isArray(errors)) {
|
|
||||||
return errors.map(convertError);
|
|
||||||
}
|
|
||||||
return [convertError(errors)];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert a record of errors to standard format
|
|
||||||
export const convertErrorRecord = (errorRecord) => {
|
|
||||||
const result = {};
|
|
||||||
|
|
||||||
if (!errorRecord) return result;
|
|
||||||
|
|
||||||
Object.entries(errorRecord).forEach(([key, errors]) => {
|
|
||||||
result[key] = convertToErrorArray(errors);
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { Field, Data, ErrorSources, ErrorType as ValidationErrorType } from '../../../types'
|
|
||||||
import { ErrorType } from '../types/index'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a price value to a consistent format
|
|
||||||
* @param value The price value to format
|
|
||||||
* @returns The formatted price string
|
|
||||||
*/
|
|
||||||
export const formatPrice = (value: string | number): string => {
|
|
||||||
if (!value) return ''
|
|
||||||
|
|
||||||
// Convert to string and clean
|
|
||||||
const numericValue = String(value).replace(/[^\d.]/g, '')
|
|
||||||
|
|
||||||
// Parse the number
|
|
||||||
const number = parseFloat(numericValue)
|
|
||||||
if (isNaN(number)) return ''
|
|
||||||
|
|
||||||
// Format as currency
|
|
||||||
return number.toLocaleString('en-US', {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a field is a price field
|
|
||||||
* @param field The field to check
|
|
||||||
* @returns True if the field is a price field
|
|
||||||
*/
|
|
||||||
export const isPriceField = (field: Field<any>): boolean => {
|
|
||||||
const fieldType = field.fieldType;
|
|
||||||
return (fieldType.type === 'input' || fieldType.type === 'multi-input') &&
|
|
||||||
'price' in fieldType &&
|
|
||||||
!!fieldType.price;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines if a field is a multi-input type
|
|
||||||
* @param fieldType The field type to check
|
|
||||||
* @returns True if the field is a multi-input type
|
|
||||||
*/
|
|
||||||
export const isMultiInputType = (fieldType: any): boolean => {
|
|
||||||
return fieldType.type === 'multi-input'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the separator for multi-input fields
|
|
||||||
* @param fieldType The field type
|
|
||||||
* @returns The separator string
|
|
||||||
*/
|
|
||||||
export const getMultiInputSeparator = (fieldType: any): string => {
|
|
||||||
if (isMultiInputType(fieldType) && fieldType.separator) {
|
|
||||||
return fieldType.separator
|
|
||||||
}
|
|
||||||
return ','
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Performs regex validation on a value
|
|
||||||
* @param value The value to validate
|
|
||||||
* @param regex The regex pattern
|
|
||||||
* @param flags Regex flags
|
|
||||||
* @returns True if validation passes
|
|
||||||
*/
|
|
||||||
export const validateRegex = (value: any, regex: string, flags?: string): boolean => {
|
|
||||||
if (value === undefined || value === null || value === '') return true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const regexObj = new RegExp(regex, flags)
|
|
||||||
return regexObj.test(String(value))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Invalid regex in validation:', error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a validation error object
|
|
||||||
* @param message Error message
|
|
||||||
* @param level Error level
|
|
||||||
* @param source Error source
|
|
||||||
* @param type Error type
|
|
||||||
* @returns Error object
|
|
||||||
*/
|
|
||||||
export const createError = (
|
|
||||||
message: string,
|
|
||||||
level: 'info' | 'warning' | 'error' = 'error',
|
|
||||||
source: ErrorSources = ErrorSources.Row,
|
|
||||||
type: ValidationErrorType = ValidationErrorType.Custom
|
|
||||||
): ErrorType => {
|
|
||||||
return {
|
|
||||||
message,
|
|
||||||
level,
|
|
||||||
source,
|
|
||||||
type
|
|
||||||
} as ErrorType
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a display value based on field type
|
|
||||||
* @param value The value to format
|
|
||||||
* @param field The field definition
|
|
||||||
* @returns Formatted display value
|
|
||||||
*/
|
|
||||||
export const getDisplayValue = (value: any, field: Field<any>): string => {
|
|
||||||
if (value === undefined || value === null) return ''
|
|
||||||
|
|
||||||
// Handle price fields
|
|
||||||
if (isPriceField(field)) {
|
|
||||||
return formatPrice(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle multi-input fields
|
|
||||||
if (isMultiInputType(field.fieldType)) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.join(`${getMultiInputSeparator(field.fieldType)} `)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle boolean values
|
|
||||||
if (typeof value === 'boolean') {
|
|
||||||
return value ? 'Yes' : 'No'
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates supplier and company fields
|
|
||||||
* @param row The data row
|
|
||||||
* @returns Object with errors for invalid fields
|
|
||||||
*/
|
|
||||||
export const validateSpecialFields = <T extends string>(row: Data<T>): Record<string, ErrorType[]> => {
|
|
||||||
const errors: Record<string, ErrorType[]> = {}
|
|
||||||
|
|
||||||
// Validate supplier field
|
|
||||||
if (!row.supplier) {
|
|
||||||
errors['supplier'] = [{
|
|
||||||
message: 'Supplier is required',
|
|
||||||
level: 'error',
|
|
||||||
source: ErrorSources.Row,
|
|
||||||
type: ValidationErrorType.Required
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate company field
|
|
||||||
if (!row.company) {
|
|
||||||
errors['company'] = [{
|
|
||||||
message: 'Company is required',
|
|
||||||
level: 'error',
|
|
||||||
source: ErrorSources.Row,
|
|
||||||
type: ValidationErrorType.Required
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merges multiple error objects
|
|
||||||
* @param errors Array of error objects to merge
|
|
||||||
* @returns Merged error object
|
|
||||||
*/
|
|
||||||
export const mergeErrors = (...errors: Record<string, ErrorType[]>[]): Record<string, ErrorType[]> => {
|
|
||||||
const merged: Record<string, ErrorType[]> = {}
|
|
||||||
|
|
||||||
errors.forEach(errorObj => {
|
|
||||||
if (!errorObj) return
|
|
||||||
|
|
||||||
Object.entries(errorObj).forEach(([key, errs]) => {
|
|
||||||
if (!merged[key]) {
|
|
||||||
merged[key] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
merged[key] = [
|
|
||||||
...merged[key],
|
|
||||||
...(Array.isArray(errs) ? errs : [errs as ErrorType])
|
|
||||||
]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user