Validate step - fix memoization and reduce unnecessary re-renders

This commit is contained in:
2025-03-05 17:02:55 -05:00
parent 05bac73c45
commit 36a5186c17
10 changed files with 1587 additions and 1089 deletions

View File

@@ -27,7 +27,7 @@ import {
} from "@/components/ui/select"
interface SearchableTemplateSelectProps {
templates: Template[];
templates: Template[] | undefined;
value: string;
onValueChange: (value: string) => void;
getTemplateDisplayText: (templateId: string | null) => string;
@@ -38,7 +38,7 @@ interface SearchableTemplateSelectProps {
}
const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
templates,
templates = [],
value,
onValueChange,
getTemplateDisplayText,
@@ -68,17 +68,15 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
// Extract unique brands from templates
const brands = useMemo(() => {
try {
if (!templates || templates.length === 0) {
console.log('No templates available for brand extraction');
if (!Array.isArray(templates) || templates.length === 0) {
return [];
}
console.log('Extracting brands from templates:', templates);
const brandSet = new Set<string>();
const brandNames: {id: string, name: string}[] = [];
templates.forEach(template => {
if (!template || !template.company) return;
if (!template?.company) return;
const companyId = template.company;
if (!brandSet.has(companyId)) {
@@ -86,21 +84,17 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
// Try to get the company name from the template display text
try {
// Extract company name from the template display text
const displayText = getTemplateDisplayText(template.id.toString());
const companyName = displayText.split(' - ')[0];
brandNames.push({ id: companyId, name: companyName || companyId });
} catch (err) {
console.error("Error extracting company name:", err);
brandNames.push({ id: companyId, name: companyId });
}
}
});
console.log('Extracted brands:', brandNames);
return brandNames.sort((a, b) => a.name.localeCompare(b.name));
} catch (err) {
console.error("Error extracting brands:", err);
return [];
}
}, [templates, getTemplateDisplayText]);
@@ -108,12 +102,12 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
// Group templates by company for better organization
const groupedTemplates = useMemo(() => {
try {
if (!templates || templates.length === 0) return {};
if (!Array.isArray(templates) || templates.length === 0) return {};
const groups: Record<string, Template[]> = {};
templates.forEach(template => {
if (!template) return;
if (!template?.company) return;
const companyId = template.company;
if (!groups[companyId]) {
@@ -124,7 +118,6 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
return groups;
} catch (err) {
console.error("Error grouping templates:", err);
return {};
}
}, [templates]);
@@ -132,12 +125,12 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
// Filter templates based on selected brand and search term
const filteredTemplates = useMemo(() => {
try {
if (!templates || templates.length === 0) return [];
if (!Array.isArray(templates) || templates.length === 0) return [];
// First filter by brand if selected
let brandFiltered = templates;
if (selectedBrand) {
brandFiltered = templates.filter(t => t && t.company === selectedBrand);
brandFiltered = templates.filter(t => t?.company === selectedBrand);
}
// Then filter by search term if provided
@@ -145,22 +138,18 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
const lowerSearchTerm = searchTerm.toLowerCase();
return brandFiltered.filter(template => {
if (!template) return false;
if (!template?.id) return false;
try {
const displayText = getTemplateDisplayText(template.id.toString());
const productType = template.product_type?.toLowerCase() || '';
// Search in both the display text and product type
return displayText.toLowerCase().includes(lowerSearchTerm) ||
productType.includes(lowerSearchTerm);
} catch (error) {
console.error("Error filtering template:", error, template);
return false;
}
});
} catch (err) {
console.error("Error in filteredTemplates:", err);
setError("Error filtering templates");
return [];
}
}, [templates, selectedBrand, searchTerm, getTemplateDisplayText]);
@@ -171,29 +160,15 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
if (!value) return placeholder;
return getTemplateDisplayText(value);
} catch (err) {
console.error("Error getting template display text:", err);
setError("Error displaying template");
return placeholder;
}
}, [getTemplateDisplayText, placeholder, value]);
// Handle errors in the component
if (error) {
return (
<Button
variant="outline"
className={cn("w-full justify-between text-destructive", triggerClassName)}
onClick={() => setError(null)}
>
Error: {error}
</Button>
);
}
// Safe render function for CommandItem
const renderCommandItem = useCallback((template: Template) => {
if (!template?.id) return null;
try {
// Get the display text for the template
const displayText = getTemplateDisplayText(template.id.toString());
return (
@@ -205,10 +180,7 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
onValueChange(currentValue);
setOpen(false);
setSearchTerm("");
// Don't reset the brand filter when selecting a template
// This allows users to keep filtering by brand
} catch (err) {
console.error("Error in onSelect:", err);
setError("Error selecting template");
}
}}
@@ -219,7 +191,6 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
</CommandItem>
);
} catch (err) {
console.error("Error rendering CommandItem:", err);
return null;
}
}, [onValueChange, value, getTemplateDisplayText]);
@@ -240,7 +211,6 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
<PopoverContent className={cn("w-[300px] p-0", className)}>
<Command>
<div className="flex flex-col p-2 gap-2">
{/* Brand filter dropdown */}
{brands.length > 0 && (
<div className="flex items-center gap-2">
<Select
@@ -254,7 +224,7 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Brands</SelectItem>
{brands && brands.length > 0 && brands.map(brand => (
{brands.map(brand => (
<SelectItem key={brand.id} value={brand.id}>
{brand.name}
</SelectItem>
@@ -266,25 +236,16 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
<CommandSeparator />
{/* Search input */}
<div className="flex items-center gap-2">
<CommandInput
placeholder="Search by product type..."
value={searchTerm}
onValueChange={(value) => {
try {
setSearchTerm(value);
} catch (err) {
console.error("Error in onValueChange:", err);
setError("Error searching templates");
}
}}
onValueChange={setSearchTerm}
className="h-8 flex-1"
/>
</div>
</div>
{/* Results */}
<CommandEmpty>
<div className="py-6 text-center">
<p className="text-sm text-muted-foreground">No templates found.</p>
@@ -294,16 +255,12 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
<CommandList>
<ScrollArea className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
{!searchTerm ? (
// When no search term is applied, show templates grouped by company
// If a brand is selected, only show that brand's templates
selectedBrand ? (
<CommandGroup heading={brands.find(b => b.id === selectedBrand)?.name || selectedBrand}>
{groupedTemplates[selectedBrand]?.map(template => renderCommandItem(template))}
</CommandGroup>
) : (
// Show all brands and their templates
Object.entries(groupedTemplates).map(([companyId, companyTemplates]) => {
// Get company name from the brands array
const brand = brands.find(b => b.id === companyId);
const companyName = brand ? brand.name : companyId;
@@ -315,9 +272,8 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
})
)
) : (
// When search term is applied, show filtered results
<CommandGroup>
{filteredTemplates.map(template => template ? renderCommandItem(template) : null)}
{filteredTemplates.map(template => renderCommandItem(template))}
</CommandGroup>
)}
</ScrollArea>

View File

@@ -1,272 +1,238 @@
import { useState, useCallback, useMemo, memo } from 'react'
import React from 'react'
import { Field } from '../../../types'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AlertCircle, AlertTriangle, Info } from 'lucide-react'
import InputCell from './cells/InputCell'
import MultiInputCell from './cells/MultiInputCell'
import SelectCell from './cells/SelectCell'
import CheckboxCell from './cells/CheckboxCell'
import { Loader2, AlertCircle } from 'lucide-react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
} from '@/components/ui/tooltip'
import InputCell from './cells/InputCell'
import SelectCell from './cells/SelectCell'
import MultiInputCell from './cells/MultiInputCell'
import { TableCell } from '@/components/ui/table'
// Define an error object type
// Define error object type
type ErrorObject = {
message: string;
level: string;
source?: string;
}
/**
* ValidationIcon - Renders an appropriate icon based on error level
*/
const ValidationIcon = memo(({ error }: { error: ErrorObject }) => {
const iconClasses = "h-4 w-4"
const icon = useMemo(() => {
switch(error.level) {
case 'error':
return <AlertCircle className={cn(iconClasses, "text-destructive")} />;
case 'warning':
return <AlertTriangle className={cn(iconClasses, "text-amber-500")} />;
case 'info':
return <Info className={cn(iconClasses, "text-blue-500")} />;
default:
return <AlertCircle className={cn(iconClasses, "text-muted-foreground")} />;
}
}, [error.level, iconClasses]);
return (
// Memoized validation icon component
const ValidationIcon = React.memo(({ error }: { error: ErrorObject }) => (
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<div className="cursor-help">{icon}</div>
<div className="cursor-help">
<AlertCircle className="h-4 w-4 text-red-500" />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px] text-wrap break-words">
<p>{error.message}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
});
export interface ValidationCellProps<T extends string> {
field: Field<T>
value: any
onChange: (value: any) => void
errors: ErrorObject[]
isValidatingUpc?: boolean
isInValidatingRow?: boolean
fieldKey?: string
options?: any[]
isLoading?: boolean
key?: string
}
// Memoized loader component
const LoadingIndicator = memo(() => (
<div className="flex items-center justify-center h-9 rounded-md border border-input bg-gray-50 px-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Validating...</span>
</div>
));
// Memoized error display component
const ErrorDisplay = memo(({ errors, isFocused }: { errors: ErrorObject[], isFocused: boolean }) => {
if (!errors || errors.length === 0) return null;
ValidationIcon.displayName = 'ValidationIcon';
return (
<>
<div className="absolute right-1 top-1/2 -translate-y-1/2">
<ValidationIcon error={errors[0]} />
</div>
{isFocused && (
<div className="text-xs text-destructive p-1 mt-1 bg-destructive/5 rounded-sm">
{errors.map((error, i) => (
<div key={i} className="py-0.5">{error.message}</div>
))}
// Separate component for item number cells to ensure they update independently
const ItemNumberCell = React.memo(({
value,
itemNumber,
isValidating,
width
}: {
value: any,
itemNumber?: string,
isValidating?: boolean,
width: number
}) => (
<TableCell className="p-1" style={{ width: `${width}px`, minWidth: `${width}px` }}>
<div className="px-2 py-1 text-sm">
{isValidating ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
<span>{itemNumber || value || ''}</span>
</div>
) : (
<span>{itemNumber || value || ''}</span>
)}
</>
);
});
</div>
</TableCell>
), (prev, next) => (
prev.value === next.value &&
prev.itemNumber === next.itemNumber &&
prev.isValidating === next.isValidating
));
// Main ValidationCell component - now with proper memoization
const ValidationCell = memo(<T extends string>(props: ValidationCellProps<T>) => {
const {
ItemNumberCell.displayName = 'ItemNumberCell';
// Memoized base cell content component
const BaseCellContent = React.memo(({
field,
value,
onChange,
errors,
isValidatingUpc = false,
fieldKey,
options } = props;
hasErrors,
options = []
}: {
field: Field<string>;
value: any;
onChange: (value: any) => void;
hasErrors: boolean;
options?: readonly any[];
}) => {
const fieldType = typeof field.fieldType === 'string'
? field.fieldType
: field.fieldType?.type || 'input';
// State for showing/hiding error messages
const [isFocused, setIsFocused] = useState(false);
// Handlers for edit state
const handleStartEdit = useCallback(() => {
setIsFocused(true);
}, []);
const handleEndEdit = useCallback(() => {
setIsFocused(false);
}, []);
// Check if this cell has errors
const hasErrors = errors && errors.length > 0;
// Show loading state when validating UPC fields
if (isValidatingUpc && (fieldKey === 'upc' || fieldKey === 'barcode' || fieldKey === 'item_number')) {
return <LoadingIndicator />;
}
// Handle cases where field might be undefined or incomplete
if (!field || !field.fieldType) {
return (
<div className="p-2 text-sm text-muted-foreground">
Error: Invalid field configuration
</div>
);
}
// Get the field type safely
const fieldType = field.fieldType.type || 'input';
// Helper for safely accessing fieldType properties
const getFieldTypeProp = (propName: string, defaultValue: any = undefined) => {
if (!field.fieldType) return defaultValue;
return (field.fieldType as any)[propName] !== undefined ?
(field.fieldType as any)[propName] :
defaultValue;
};
// Memoize the cell content to prevent unnecessary re-renders
const cellContent = useMemo(() => {
// Handle custom options for select fields first
if ((fieldType === 'select' || fieldType === 'multi-select') && options && options.length > 0) {
try {
if (fieldType === 'select') {
return (
<SelectCell
field={field}
value={value}
onChange={onChange}
onStartEdit={handleStartEdit}
onEndEdit={handleEndEdit}
hasErrors={hasErrors}
options={options}
hasErrors={hasErrors}
/>
);
} catch (error) {
console.error("Error rendering SelectCell with custom options:", error);
return <div className="p-2 text-destructive">Error rendering field</div>;
}
}
// Standard rendering based on field type
try {
switch (fieldType) {
case 'input':
return (
<InputCell
field={field}
value={value}
onChange={onChange}
onStartEdit={handleStartEdit}
onEndEdit={handleEndEdit}
hasErrors={hasErrors}
isMultiline={getFieldTypeProp('multiline', false)}
isPrice={getFieldTypeProp('price', false)}
/>
);
case 'multi-input':
if (fieldType === 'multi-select' || fieldType === 'multi-input') {
return (
<MultiInputCell
field={field}
value={value}
onChange={onChange}
onStartEdit={handleStartEdit}
onEndEdit={handleEndEdit}
options={options}
hasErrors={hasErrors}
separator={getFieldTypeProp('separator', ',')}
isMultiline={getFieldTypeProp('multiline', false)}
isPrice={getFieldTypeProp('price', false)}
/>
);
case 'select':
case 'multi-select':
return (
<SelectCell
field={field}
value={value}
onChange={onChange}
onStartEdit={handleStartEdit}
onEndEdit={handleEndEdit}
hasErrors={hasErrors}
options={getFieldTypeProp('options', [])}
/>
);
case 'checkbox':
return (
<CheckboxCell
field={field}
value={value}
onChange={onChange}
hasErrors={hasErrors}
booleanMatches={getFieldTypeProp('booleanMatches', {})}
/>
);
default:
}
return (
<InputCell
field={field}
value={value}
onChange={onChange}
onStartEdit={handleStartEdit}
onEndEdit={handleEndEdit}
hasErrors={hasErrors}
/>
);
}
} catch (error) {
console.error(`Error rendering cell of type ${fieldType}:`, error);
}, (prev, next) => {
return (
<div className="p-2 text-destructive">
Error rendering field
</div>
);
}
}, [
fieldType,
field,
value,
onChange,
handleStartEdit,
handleEndEdit,
hasErrors,
options,
getFieldTypeProp
]);
return (
<div className={cn(
"relative",
hasErrors && "space-y-1"
)}>
{cellContent}
{/* Render errors if any exist */}
{hasErrors && <ErrorDisplay errors={errors} isFocused={isFocused} />}
</div>
prev.value === next.value &&
prev.hasErrors === next.hasErrors &&
prev.field === next.field &&
JSON.stringify(prev.options) === JSON.stringify(next.options)
);
});
ValidationCell.displayName = 'ValidationCell';
BaseCellContent.displayName = 'BaseCellContent';
export default ValidationCell;
export interface ValidationCellProps {
field: Field<string>
value: any
onChange: (value: any) => void
errors: ErrorObject[]
isValidating?: boolean
fieldKey: string
options?: readonly any[]
itemNumber?: string
width: number
rowIndex: number
}
const ValidationCell = ({
field,
value,
onChange,
errors,
isValidating,
fieldKey,
options = [],
itemNumber,
width}: ValidationCellProps) => {
// For item_number fields, use the specialized component
if (fieldKey === 'item_number') {
return (
<ItemNumberCell
value={value}
itemNumber={itemNumber}
isValidating={isValidating}
width={width}
/>
);
}
// For UPC fields, show loading indicator during validation
const isUpcField = fieldKey === 'upc' || fieldKey === 'barcode';
const showLoadingIndicator = isUpcField && isValidating;
// Error states
const hasError = errors.some(error => error.level === 'error' || error.level === 'warning');
const isRequiredButEmpty = errors.some(error => error.level === 'required' && (!value || value.trim() === ''));
const nonRequiredErrors = errors.filter(error => error.level !== 'required');
return (
<TableCell className="p-1" style={{ width: `${width}px`, minWidth: `${width}px` }}>
<div className={`relative ${hasError || isRequiredButEmpty ? 'border-red-500' : ''} ${showLoadingIndicator ? 'border-blue-500' : ''}`}>
{showLoadingIndicator && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-10">
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
</div>
)}
<div className="truncate overflow-hidden">
<BaseCellContent
field={field}
value={value}
onChange={onChange}
hasErrors={hasError || isRequiredButEmpty}
options={options}
/>
</div>
{nonRequiredErrors.length > 0 && !isRequiredButEmpty && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-20">
<ValidationIcon error={{
message: nonRequiredErrors.map(e => e.message).join('\n'),
level: 'error'
}} />
</div>
)}
</div>
</TableCell>
);
};
export default React.memo(ValidationCell, (prev, next) => {
// Deep comparison of errors
const prevErrorsStr = JSON.stringify(prev.errors);
const nextErrorsStr = JSON.stringify(next.errors);
// For item number fields, compare itemNumber
if (prev.fieldKey === 'item_number') {
return (
prev.value === next.value &&
prev.itemNumber === next.itemNumber &&
prev.isValidating === next.isValidating
);
}
// For UPC fields, include validation state in comparison
if (prev.fieldKey === 'upc' || prev.fieldKey === 'barcode') {
return (
prev.value === next.value &&
prevErrorsStr === nextErrorsStr &&
prev.isValidating === next.isValidating
);
}
// For all other fields, compare core props
return (
prev.value === next.value &&
prevErrorsStr === nextErrorsStr &&
JSON.stringify(prev.options) === JSON.stringify(next.options)
);
});

View File

@@ -548,32 +548,54 @@ const ValidationContainer = <T extends string>({
}, [data, rowSelection, setData, setRowSelection]);
// Enhanced ValidationTable component that's aware of item numbers
const EnhancedValidationTable = useCallback(({
data,
...props
}: React.ComponentProps<typeof ValidationTable<T>>) => {
const EnhancedValidationTable = useCallback((props: React.ComponentProps<typeof ValidationTable>) => {
// Create validatingCells set from validatingUpcRows
const validatingCells = useMemo(() => {
const cells = new Set<string>();
validatingUpcRows.forEach(rowIndex => {
cells.add(`${rowIndex}-upc`);
cells.add(`${rowIndex}-item_number`);
});
return cells;
}, [validatingUpcRows]);
// Convert itemNumbers to Map
const itemNumbersMap = useMemo(() =>
new Map(Object.entries(itemNumbers).map(([key, value]) => [parseInt(key), value])),
[itemNumbers]
);
// Merge the item numbers with the data for display purposes only
const enhancedData = useMemo(() => {
if (Object.keys(itemNumbers).length === 0) return data;
if (Object.keys(itemNumbers).length === 0) return props.data;
// Create a new array with the item numbers merged in
return data.map((row, index) => {
return props.data.map((row: any, index: number) => {
if (itemNumbers[index]) {
return { ...row, item_number: itemNumbers[index] };
}
return row;
});
}, [data]);
}, [props.data]);
return <ValidationTable<T> data={enhancedData} {...props} />;
}, [itemNumbers]);
return (
<ValidationTable
{...props}
data={enhancedData}
validatingCells={validatingCells}
itemNumbers={itemNumbersMap}
/>
);
}, [itemNumbers, validatingUpcRows]);
// Memoize the ValidationTable to prevent unnecessary re-renders
const renderValidationTable = useMemo(() => (
<EnhancedValidationTable
data={filteredData}
fields={validationState.fields}
updateRow={enhancedUpdateRow}
updateRow={(rowIndex: number, key: string, value: any) =>
enhancedUpdateRow(rowIndex, key as T, value)
}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
validationErrors={validationErrors}
@@ -587,6 +609,9 @@ const ValidationContainer = <T extends string>({
rowSublines={rowSublines}
isLoadingLines={isLoadingLines}
isLoadingSublines={isLoadingSublines}
upcValidationResults={new Map(Object.entries(itemNumbers).map(([key, value]) => [parseInt(key), { itemNumber: value }]))}
validatingCells={new Set()}
itemNumbers={new Map()}
/>
), [
EnhancedValidationTable,
@@ -605,7 +630,8 @@ const ValidationContainer = <T extends string>({
rowProductLines,
rowSublines,
isLoadingLines,
isLoadingSublines
isLoadingSublines,
itemNumbers
]);
return (

View File

@@ -0,0 +1,65 @@
import React, { useEffect, useCallback } from 'react';
import { useValidationState } from '../hooks/useValidationState';
import ValidationTable from './ValidationTable';
import { Fields, Field } from '../../../types';
import { RowData } from '../hooks/useValidationState';
interface ValidationStepProps<T extends string> {
data: RowData<T>[];
fields: Fields<T>;
onContinue?: (data: RowData<T>[]) => void;
onBack?: () => void;
initialValidationErrors?: Map<number, Record<string, any>>;
initialValidationState?: Map<number, 'pending' | 'validating' | 'validated' | 'error'>;
}
export const ValidationStep = <T extends string>({
data,
fields,
onContinue,
onBack,
initialValidationErrors,
initialValidationState,
}: ValidationStepProps<T>) => {
const {
data: rowData,
validationErrors,
rowValidationStatus,
validateRow,
hasErrors,
fields: fieldsWithOptions,
rowSelection,
setRowSelection,
updateRow,
isValidatingUpc,
validatingUpcRows,
filters,
templates,
applyTemplate,
getTemplateDisplayText,
} = useValidationState({
initialData: data,
fields,
onNext: onContinue,
onBack,
});
return (
<div className="flex flex-col h-full">
<ValidationTable
data={rowData}
fields={fieldsWithOptions}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
updateRow={updateRow}
validationErrors={validationErrors}
isValidatingUpc={isValidatingUpc}
validatingUpcRows={validatingUpcRows}
filters={filters}
templates={templates}
applyTemplate={applyTemplate}
getTemplateDisplayText={getTemplateDisplayText}
/>
</div>
);
};

View File

@@ -3,14 +3,15 @@ import {
useReactTable,
getCoreRowModel,
flexRender,
createColumnHelper,
RowSelectionState} from '@tanstack/react-table'
RowSelectionState,
ColumnDef
} from '@tanstack/react-table'
import { Fields, Field } from '../../../types'
import { RowData, Template } from '../hooks/useValidationState'
import { Checkbox } from '@/components/ui/checkbox'
import ValidationCell from './ValidationCell'
import { useRsi } from '../../../hooks/useRsi'
import SearchableTemplateSelect from './SearchableTemplateSelect'
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
// Define a simple Error type locally to avoid import issues
type ErrorType = {
@@ -36,169 +37,142 @@ interface ValidationTableProps<T extends string> {
rowSublines?: Record<string, any[]>
isLoadingLines?: Record<string, boolean>
isLoadingSublines?: Record<string, boolean>
upcValidationResults: Map<number, { itemNumber: string }>
validatingCells: Set<string>
itemNumbers: Map<number, string>
[key: string]: any
}
// Memoized cell component to prevent unnecessary re-renders
const MemoizedCell = React.memo(
({
rowIndex,
// Make Field type mutable for internal use
type MutableField<T extends string> = {
-readonly [K in keyof Field<T>]: Field<T>[K] extends readonly (infer U)[] ? U[] : Field<T>[K]
}
interface MemoizedCellProps<T extends string = string> {
field: MutableField<T>
value: any
rowIndex: number
updateRow: (rowIndex: number, key: string, value: any) => void
validationErrors: Map<number, Record<string, ErrorType[]>>
validatingCells: Set<string>
itemNumbers: Map<number, string>
width: number
}
// Memoized cell component that only updates when its specific data changes
const MemoizedCell = React.memo(({
field,
value,
errors,
isValidatingUpc,
fieldOptions,
isOptionsLoading,
rowIndex,
updateRow,
columnId
}: {
rowIndex: number
field: Field<any>
value: any
errors: ErrorType[]
isValidatingUpc: (rowIndex: number) => boolean
fieldOptions?: any[]
isOptionsLoading?: boolean
updateRow: (rowIndex: number, key: any, value: any) => void
columnId: string
}) => {
const handleChange = (newValue: any) => {
updateRow(rowIndex, columnId, newValue);
};
validationErrors,
validatingCells,
itemNumbers,
width
}: MemoizedCellProps) => {
const rowErrors = validationErrors.get(rowIndex) || {};
const fieldErrors = rowErrors[String(field.key)] || [];
const isValidating = validatingCells.has(`${rowIndex}-${field.key}`);
const options = field.fieldType.type === 'select' || field.fieldType.type === 'multi-select'
? Array.from(field.fieldType.options || [])
: [];
return (
<ValidationCell
value={value}
field={field}
onChange={handleChange}
errors={errors || []}
isValidatingUpc={isValidatingUpc(rowIndex)}
fieldKey={columnId}
options={fieldOptions}
isLoading={isOptionsLoading}
value={value}
onChange={(newValue) => updateRow(rowIndex, field.key, newValue)}
errors={fieldErrors}
isValidating={isValidating}
fieldKey={String(field.key)}
options={options}
itemNumber={itemNumbers.get(rowIndex)}
width={width}
rowIndex={rowIndex}
/>
);
},
// Custom comparison function for the memo
(prevProps, nextProps) => {
// Re-render only if any of these props changed
return (
prevProps.value === nextProps.value &&
prevProps.errors === nextProps.errors &&
prevProps.fieldOptions === nextProps.fieldOptions &&
prevProps.isOptionsLoading === nextProps.isOptionsLoading &&
prevProps.isValidatingUpc(prevProps.rowIndex) === nextProps.isValidatingUpc(nextProps.rowIndex)
);
}
);
}, (prev, next) => {
const fieldKey = String(prev.field.key);
// Memoized template cell component
const MemoizedTemplateCell = React.memo(
({
rowIndex,
templateValue,
templates,
applyTemplate,
getTemplateDisplayText
}: {
rowIndex: number
templateValue: string | null
templates: Template[]
applyTemplate: (templateId: string, rowIndexes: number[]) => void
getTemplateDisplayText: (templateId: string | null) => string
}) => {
const handleTemplateChange = (value: string) => {
applyTemplate(value, [rowIndex]);
};
// For item_number fields, only update if the item number or validation state changes
if (fieldKey === 'item_number') {
const prevItemNumber = prev.itemNumbers.get(prev.rowIndex);
const nextItemNumber = next.itemNumbers.get(next.rowIndex);
const prevValidating = prev.validatingCells.has(`${prev.rowIndex}-item_number`);
const nextValidating = next.validatingCells.has(`${next.rowIndex}-item_number`);
return (
<SearchableTemplateSelect
templates={templates}
value={templateValue || ''}
onValueChange={handleTemplateChange}
getTemplateDisplayText={(template) =>
template ? getTemplateDisplayText(template) : 'Select template'
}
/>
);
},
// Custom comparison function for the memo
(prevProps, nextProps) => {
return (
prevProps.templateValue === nextProps.templateValue &&
prevProps.templates === nextProps.templates
prevItemNumber === nextItemNumber &&
prevValidating === nextValidating &&
prev.value === next.value
);
}
);
const ValidationTable = <T extends string>({
data,
// For UPC fields, only update if the value or validation state changes
if (fieldKey === 'upc' || fieldKey === 'barcode') {
const prevValidating = prev.validatingCells.has(`${prev.rowIndex}-${fieldKey}`);
const nextValidating = next.validatingCells.has(`${next.rowIndex}-${fieldKey}`);
return (
prev.value === next.value &&
prevValidating === nextValidating
);
}
// For other fields, only update if the value or errors change
const prevErrors = prev.validationErrors.get(prev.rowIndex)?.[fieldKey];
const nextErrors = next.validationErrors.get(next.rowIndex)?.[fieldKey];
return (
prev.value === next.value &&
JSON.stringify(prevErrors) === JSON.stringify(nextErrors)
);
});
MemoizedCell.displayName = 'MemoizedCell';
interface MemoizedRowProps {
row: RowData<string>;
fields: readonly {
readonly label: string;
readonly key: string;
readonly description?: string;
readonly alternateMatches?: readonly string[];
readonly validations?: readonly any[];
readonly fieldType: any;
readonly example?: string;
readonly width?: number;
readonly disabled?: boolean;
}[];
updateRow: (rowIndex: number, key: string, value: any) => void;
validationErrors: Map<number, Record<string, ErrorType[]>>;
validatingCells: Set<string>;
itemNumbers: Map<number, string>;
options?: { [key: string]: any[] };
rowIndex: number;
isSelected: boolean;
}
const MemoizedRow = React.memo<MemoizedRowProps>(({
row,
fields,
rowSelection,
setRowSelection,
updateRow,
validationErrors,
isValidatingUpc,
filters,
templates,
applyTemplate,
getTemplateDisplayText,
rowProductLines = {},
rowSublines = {},
isLoadingLines = {},
isLoadingSublines = {}}: ValidationTableProps<T>) => {
const { translations } = useRsi<T>()
const columnHelper = createColumnHelper<RowData<T>>()
// Define columns for the table
const columns = useMemo(() => {
// Selection column
const selectionColumn = columnHelper.display({
id: 'select',
header: ({ table }) => (
<div className="flex justify-center">
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
size: 50,
});
// Template column
const templateColumn = columnHelper.display({
id: 'template',
header: 'Template',
cell: ({ row }) => {
const rowIndex = row.index;
validatingCells,
itemNumbers,
options = {},
rowIndex,
isSelected
}) => {
return (
<MemoizedTemplateCell
rowIndex={rowIndex}
templateValue={row.original.__template || null}
templates={templates}
applyTemplate={applyTemplate}
getTemplateDisplayText={getTemplateDisplayText}
/>
);
},
size: 200,
});
<TableRow
key={row.__index || rowIndex}
data-state={isSelected && "selected"}
className={validationErrors.get(rowIndex) ? "bg-red-50/40" : "hover:bg-muted/50"}
>
{fields.map((field) => {
if (field.disabled) return null;
// Create columns for each field
const fieldColumns = fields.map(field => {
// Get the field width directly from the field definition
// These are exactly the values defined in Import.tsx
const fieldWidth = field.width || (
field.fieldType.type === "checkbox" ? 80 :
field.fieldType.type === "select" ? 150 :
@@ -208,167 +182,208 @@ const ValidationTable = <T extends string>({
150
);
return columnHelper.accessor(
(row: RowData<T>) => row[field.key as keyof typeof row],
{
id: field.key,
header: field.label,
cell: ({ row, column }) => {
try {
const rowIndex = row.index;
const value = row.getValue(column.id);
const errors = validationErrors.get(rowIndex)?.[column.id] || [];
const rowId = row.original?.__index;
// Determine if we have custom options for this field
let fieldOptions;
let isOptionsLoading = false;
// Handle line field - use company-specific product lines
if (field.key === 'line' && rowId && rowProductLines[rowId]) {
fieldOptions = rowProductLines[rowId];
isOptionsLoading = isLoadingLines[rowId] || false;
}
// Handle subline field - use line-specific sublines
else if (field.key === 'subline' && rowId && rowSublines[rowId]) {
fieldOptions = rowSublines[rowId];
isOptionsLoading = isLoadingSublines[rowId] || false;
}
// Cast the field type for ValidationCell
const typedField = field as Field<string>;
const isValidating = validatingCells.has(`${rowIndex}-${field.key}`);
return (
<MemoizedCell
<ValidationCell
key={String(field.key)}
field={field as Field<string>}
value={row[field.key]}
onChange={(value) => updateRow(rowIndex, field.key, value)}
errors={validationErrors.get(rowIndex)?.[String(field.key)] || []}
isValidating={isValidating}
fieldKey={String(field.key)}
options={options[String(field.key)] || []}
width={fieldWidth}
rowIndex={rowIndex}
field={typedField}
value={value}
errors={errors}
isValidatingUpc={isValidatingUpc}
fieldOptions={fieldOptions}
isOptionsLoading={isOptionsLoading}
updateRow={updateRow}
columnId={column.id}
itemNumber={itemNumbers.get(rowIndex)}
/>
);
} catch (error) {
console.error(`Error rendering cell for column ${column.id}:`, error);
return (
<div className="p-2 text-destructive text-sm">
Error rendering cell
</div>
);
}
},
size: fieldWidth,
}
})}
</TableRow>
);
}, (prev, next) => {
// Compare row data
const prevRowStr = JSON.stringify(prev.row);
const nextRowStr = JSON.stringify(next.row);
if (prevRowStr !== nextRowStr) return false;
// Compare validation errors for this row
const prevErrors = prev.validationErrors.get(prev.rowIndex);
const nextErrors = next.validationErrors.get(next.rowIndex);
if (JSON.stringify(prevErrors) !== JSON.stringify(nextErrors)) return false;
// Compare validation state for this row's cells
const prevValidatingCells = Array.from(prev.validatingCells)
.filter(key => key.startsWith(`${prev.rowIndex}-`));
const nextValidatingCells = Array.from(next.validatingCells)
.filter(key => key.startsWith(`${next.rowIndex}-`));
if (JSON.stringify(prevValidatingCells) !== JSON.stringify(nextValidatingCells)) return false;
// Compare item numbers for this row
const prevItemNumber = prev.itemNumbers.get(prev.rowIndex);
const nextItemNumber = next.itemNumbers.get(next.rowIndex);
if (prevItemNumber !== nextItemNumber) return false;
// Compare selection state
if (prev.isSelected !== next.isSelected) return false;
return true;
});
return [selectionColumn, templateColumn, ...fieldColumns];
}, [
columnHelper,
MemoizedRow.displayName = 'MemoizedRow';
const ValidationTable = <T extends string>({
data,
fields,
rowSelection,
setRowSelection,
updateRow,
validationErrors,
filters,
templates,
applyTemplate,
getTemplateDisplayText,
rowProductLines,
rowSublines,
isLoadingLines,
isLoadingSublines,
validationErrors,
isValidatingUpc,
updateRow
]);
validatingCells,
itemNumbers
}: ValidationTableProps<T>) => {
const { translations } = useRsi<T>();
// Memoize the template column
const templateColumn = useMemo((): ColumnDef<RowData<T>, any> => ({
accessorKey: '__template',
header: 'Template',
size: 200,
cell: ({ row }) => {
const templateValue = row.original.__template || null;
return (
<TableCell className="p-1" style={{ width: '200px', minWidth: '200px' }}>
<SearchableTemplateSelect
templates={templates}
value={templateValue || ''}
onValueChange={(value) => {
applyTemplate(value, [row.index]);
}}
getTemplateDisplayText={(template) =>
template ? getTemplateDisplayText(template) : 'Select template'
}
/>
</TableCell>
);
}
}), [templates, applyTemplate, getTemplateDisplayText]);
// Memoize field columns
const fieldColumns = useMemo(() => fields.map((field): ColumnDef<RowData<T>, any> | null => {
if (field.disabled) return null;
const fieldWidth = field.width || (
field.fieldType.type === "checkbox" ? 80 :
field.fieldType.type === "select" ? 150 :
field.fieldType.type === "multi-select" ? 200 :
(field.fieldType.type === "input" || field.fieldType.type === "multi-input") &&
(field.fieldType as any).multiline ? 300 :
150
);
return {
accessorKey: String(field.key),
header: field.label || String(field.key),
size: fieldWidth,
cell: ({ row }) => {
const cellUpdateRow = (rowIndex: number, key: string, value: any) => {
updateRow(rowIndex, key as T, value);
};
return (
<MemoizedCell
field={field as MutableField<T>}
value={row.original[field.key as keyof typeof row.original]}
rowIndex={row.index}
updateRow={cellUpdateRow}
validationErrors={validationErrors}
validatingCells={validatingCells}
itemNumbers={itemNumbers}
width={fieldWidth}
/>
);
}
};
}).filter((col): col is ColumnDef<RowData<T>, any> => col !== null), [fields, validationErrors, validatingCells, itemNumbers, updateRow]);
// Combine columns
const columns = useMemo(() => [templateColumn, ...fieldColumns], [templateColumn, fieldColumns]);
// Initialize table
const table = useReactTable({
data,
columns,
state: {
rowSelection,
},
getCoreRowModel: getCoreRowModel(),
state: { rowSelection },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.__index || String(row.index)
});
// Apply filters to rows if needed
const filteredRows = useMemo(() => {
let rows = table.getRowModel().rows;
if (filters?.showErrorsOnly) {
rows = rows.filter(row => {
const rowIndex = row.index;
return validationErrors.has(rowIndex) &&
Object.values(validationErrors.get(rowIndex) || {}).some(errors => errors.length > 0);
});
// Don't render if no data
if (data.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">
{filters?.showErrorsOnly
? translations.validationStep.noRowsMessageWhenFiltered || "No rows with errors"
: translations.validationStep.noRowsMessage || "No data to display"}
</p>
</div>
);
}
return rows;
}, [table, filters, validationErrors]);
return (
<div className="h-full flex flex-col">
<div className="overflow-auto flex-1">
<table className="w-full border-separate border-spacing-0" style={{ tableLayout: 'fixed' }}>
<thead className="sticky top-0 z-10 bg-background border-b h-5">
<tr>
{table.getHeaderGroups()[0].headers.map((header) => (
<th
<Table>
<TableHeader>
<TableRow>
{table.getFlatHeaders().map((header) => (
<TableHead
key={header.id}
style={{
width: `${header.getSize()}px`,
minWidth: `${header.getSize()}px`,
minWidth: `${header.getSize()}px`
}}
className="h-10 py-3 px-3 text-left text-muted-foreground font-medium text-sm bg-muted"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</tr>
</thead>
<tbody>
{filteredRows.length ? (
filteredRows.map((row) => (
<tr
</TableRow>
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() ? "selected" : undefined}
className={validationErrors.has(row.index) ? "bg-red-50/30" : ""}
data-state={row.getIsSelected() && "selected"}
className={validationErrors.get(row.index) ? "bg-red-50/40" : "hover:bg-muted/50"}
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
style={{
width: `${cell.column.getSize()}px`,
minWidth: `${cell.column.getSize()}px`,
}}
className="p-1 align-middle border-b border-muted"
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
flexRender(cell.column.columnDef.cell, cell.getContext())
))}
</tr>
))
) : (
<tr>
<td colSpan={columns.length} className="h-24 text-center">
{filters?.showErrorsOnly
? translations.validationStep.noRowsMessageWhenFiltered || "No rows with errors found."
: translations.validationStep.noRowsMessage || "No rows found."}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</TableRow>
))}
</TableBody>
</Table>
);
};
export default ValidationTable;
export default React.memo(ValidationTable, (prev, next) => {
// Deep compare data
if (JSON.stringify(prev.data) !== JSON.stringify(next.data)) return false;
// Compare validation errors
if (JSON.stringify(Array.from(prev.validationErrors.entries())) !==
JSON.stringify(Array.from(next.validationErrors.entries()))) return false;
// Compare filters
if (JSON.stringify(prev.filters) !== JSON.stringify(next.filters)) return false;
// Compare row selection
if (JSON.stringify(prev.rowSelection) !== JSON.stringify(next.rowSelection)) return false;
return true;
});

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'
import React, { useState, useCallback } from 'react'
import { Field } from '../../../../types'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
@@ -25,34 +25,26 @@ const InputCell = <T extends string>({
isMultiline = false,
isPrice = false
}: InputCellProps<T>) => {
const [inputValue, setInputValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
// Initialize input value
useEffect(() => {
if (value !== undefined && value !== null) {
setInputValue(String(value))
} else {
setInputValue('')
}
}, [value])
const [editValue, setEditValue] = useState('')
// Handle focus event
const handleFocus = useCallback(() => {
setIsEditing(true)
setEditValue(value !== undefined && value !== null ? String(value) : '')
onStartEdit?.()
}, [onStartEdit])
}, [value, onStartEdit])
// Handle blur event
const handleBlur = useCallback(() => {
setIsEditing(false)
// Format the value for storage (remove formatting like $ for price)
let processedValue = inputValue
let processedValue = editValue
if (isPrice) {
// Remove any non-numeric characters except decimal point
processedValue = inputValue.replace(/[^\d.]/g, '')
processedValue = editValue.replace(/[^\d.]/g, '')
// Parse as float and format to 2 decimal places to ensure valid number
const numValue = parseFloat(processedValue)
@@ -63,21 +55,21 @@ const InputCell = <T extends string>({
onChange(processedValue)
onEndEdit?.()
}, [inputValue, onChange, onEndEdit, isPrice])
}, [editValue, onChange, onEndEdit, isPrice])
// Format price value for display
const getDisplayValue = useCallback(() => {
if (!isPrice || !inputValue) return inputValue
if (!isPrice || !value) return value
// Extract numeric part
const numericValue = inputValue.replace(/[^\d.]/g, '')
const numericValue = String(value).replace(/[^\d.]/g, '')
// Parse as float and format with dollar sign
const numValue = parseFloat(numericValue)
if (isNaN(numValue)) return inputValue
if (isNaN(numValue)) return value
return `$${numValue.toFixed(2)}`
}, [inputValue, isPrice])
}, [value, isPrice])
// Add outline even when not in focus
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
@@ -86,8 +78,8 @@ const InputCell = <T extends string>({
<div className="w-full">
{isMultiline ? (
<Textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
value={isEditing ? editValue : (value ?? '')}
onChange={(e) => setEditValue(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
className={cn(
@@ -100,8 +92,8 @@ const InputCell = <T extends string>({
isEditing ? (
<Input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
autoFocus
@@ -119,7 +111,7 @@ const InputCell = <T extends string>({
hasErrors ? "border-destructive" : "border-input"
)}
>
{isPrice ? getDisplayValue() : (inputValue)}
{isPrice ? getDisplayValue() : (value ?? '')}
</div>
)
)}
@@ -127,4 +119,13 @@ const InputCell = <T extends string>({
)
}
export default InputCell
// Memoize the component with a strict comparison function
export default React.memo(InputCell, (prev, next) => {
// Only re-render if these props change
return (
prev.value === next.value &&
prev.hasErrors === next.hasErrors &&
prev.isMultiline === next.isMultiline &&
prev.isPrice === next.isPrice
)
})

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { Field } from '../../../../types'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
@@ -9,22 +9,35 @@ import { Button } from '@/components/ui/button'
import { Check, ChevronsUpDown, X } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
// Define a type for field options
interface FieldOption {
label: string;
value: string;
}
// Define extended field type that includes multi-select
interface MultiSelectFieldType {
type: 'multi-select';
options?: readonly FieldOption[];
separator?: string;
}
interface MultiInputCellProps<T extends string> {
field: Field<T>
value: any
onChange: (value: any) => void
value: string[]
onChange: (value: string[]) => void
onStartEdit?: () => void
onEndEdit?: () => void
hasErrors?: boolean
separator?: string
isMultiline?: boolean
isPrice?: boolean
options?: readonly { label: string; value: string }[]
options?: readonly FieldOption[]
}
const MultiInputCell = <T extends string>({
field,
value,
value = [],
onChange,
onStartEdit,
onEndEdit,
@@ -35,106 +48,117 @@ const MultiInputCell = <T extends string>({
options: providedOptions
}: MultiInputCellProps<T>) => {
const [open, setOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
const [selectedValues, setSelectedValues] = useState<string[]>([])
const [isEditing, setIsEditing] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
// Check if field is a multi-select field
const isMultiSelect = field.fieldType.type === 'multi-select'
const fieldOptions = isMultiSelect && field.fieldType.options
? field.fieldType.options
: undefined
// Memoize field options to prevent unnecessary recalculations
const selectOptions = useMemo(() => {
const fieldType = field.fieldType;
const fieldOptions = fieldType &&
(fieldType.type === 'select' || fieldType.type === 'multi-select') &&
fieldType.options ?
fieldType.options :
[];
// Convert options to a regular array to avoid issues with readonly arrays
const options = providedOptions
? Array.from(providedOptions)
: (fieldOptions ? Array.from(fieldOptions) : [])
// Use provided options or field options, ensuring they have the correct shape
const availableOptions = (providedOptions || fieldOptions || []).map(option => ({
label: option.label || String(option.value),
value: String(option.value)
}));
// Initialize input value and selected values
useEffect(() => {
if (value !== undefined && value !== null) {
setInputValue(String(value))
// Split the value based on separator for display
const valueArray = String(value).split(separator).map(v => v.trim()).filter(Boolean)
setSelectedValues(valueArray)
} else {
setInputValue('')
setSelectedValues([])
// Add default option if no options available
if (availableOptions.length === 0) {
availableOptions.push({ label: 'No options available', value: '' });
}
}, [value, separator])
return availableOptions;
}, [field.fieldType, providedOptions]);
// Memoize filtered options based on search query
const filteredOptions = useMemo(() => {
if (!searchQuery) return selectOptions;
return selectOptions.filter(option =>
option.label.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [selectOptions, searchQuery]);
// Memoize selected values display
const selectedValues = useMemo(() => {
return value.map(v => {
const option = selectOptions.find(opt => String(opt.value) === String(v));
return {
value: v,
label: option ? option.label : String(v)
};
});
}, [value, selectOptions]);
const handleSelect = useCallback((selectedValue: string) => {
const newValue = value.includes(selectedValue)
? value.filter(v => v !== selectedValue)
: [...value, selectedValue];
onChange(newValue);
setSearchQuery("");
}, [value, onChange]);
const handleRemove = useCallback((valueToRemove: string) => {
onChange(value.filter(v => v !== valueToRemove));
}, [value, onChange]);
// Handle focus
const handleFocus = useCallback(() => {
setIsEditing(true)
onStartEdit?.()
}, [onStartEdit])
if (onStartEdit) onStartEdit();
}, [onStartEdit]);
// Handle blur
const handleBlur = useCallback(() => {
setIsEditing(false)
if (onEndEdit) onEndEdit();
}, [onEndEdit]);
// Format all values and join with separator
let processedValues = selectedValues
if (isPrice) {
// Format all values as prices
processedValues = selectedValues.map(val => {
const numValue = parseFloat(val.replace(/[^\d.]/g, ''))
return !isNaN(numValue) ? numValue.toFixed(2) : val
})
}
// Join all values with the separator
const joinedValue = processedValues.join(separator + ' ')
onChange(joinedValue)
onEndEdit?.()
}, [selectedValues, onChange, onEndEdit, isPrice, separator])
// Toggle a value selection
const toggleValue = useCallback((value: string) => {
setSelectedValues(current => {
const isSelected = current.includes(value)
if (isSelected) {
return current.filter(v => v !== value)
} else {
return [...current, value]
}
})
}, [])
// Remove a selected value
const removeValue = useCallback((value: string) => {
setSelectedValues(current => current.filter(v => v !== value))
}, [])
// Handle direct input changes
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const newValue = e.target.value;
onChange(newValue.split(separator).map(v => v.trim()).filter(Boolean));
}, [separator, onChange]);
// Format display values for price
const getDisplayValues = useCallback(() => {
if (!isPrice) return selectedValues
if (!isPrice) return selectedValues.map(v => v.label);
return selectedValues.map(val => {
const numValue = parseFloat(val.replace(/[^\d.]/g, ''))
return !isNaN(numValue) ? `$${numValue.toFixed(2)}` : val
})
}, [selectedValues, isPrice])
return selectedValues.map(v => {
const numValue = parseFloat(v.value.replace(/[^\d.]/g, ''))
return !isNaN(numValue) ? `$${numValue.toFixed(2)}` : v.value
});
}, [selectedValues, isPrice]);
// Add outline even when not in focus
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
// If we have a multi-select field with options, use command UI
if (isMultiSelect && options.length > 0) {
// Ensure all options have the expected shape
const safeOptions = options.map(option => ({
label: option.label || String(option.value),
value: String(option.value)
}));
// Make sure selectedValues are all strings
const safeSelectedValues = selectedValues.map(String);
if (field.fieldType.type === 'multi-select' && selectOptions.length > 0) {
return (
<div className="w-full">
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-1">
{selectedValues.map(({ value: val, label }) => (
<Badge
key={val}
variant="secondary"
className={cn(
"mr-1 mb-1",
hasErrors && "bg-red-100 hover:bg-red-200"
)}
>
{label}
<button
type="button"
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
onClick={() => handleRemove(val)}
>
<X className="h-3 w-3" />
<span className="sr-only">Remove</span>
</button>
</Badge>
))}
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
@@ -142,72 +166,40 @@ const MultiInputCell = <T extends string>({
role="combobox"
aria-expanded={open}
className={cn(
"w-full justify-between font-normal min-h-10",
outlineClass,
"text-left",
hasErrors ? "border-destructive" : ""
"w-full justify-between font-normal",
!value.length && "text-muted-foreground",
hasErrors && "border-red-500"
)}
onClick={() => {
setOpen(!open)
handleFocus()
}}
onClick={handleFocus}
>
<div className="flex flex-wrap gap-1 max-w-[90%]">
{safeSelectedValues.length > 0 ? (
safeSelectedValues.map(value => {
const option = safeOptions.find(opt => opt.value === value);
return (
<Badge
key={value}
variant="secondary"
className="mr-1 mb-1"
>
{option ? option.label : value}
<button
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
onMouseDown={e => {
e.preventDefault()
e.stopPropagation()
removeValue(value)
}}
>
<X className="h-3 w-3" />
</button>
</Badge>
)
})
) : (
<span className="text-muted-foreground">{ "Select options..."}</span>
)}
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
{value.length === 0 ? "Select..." : `${value.length} selected`}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="Search options..." />
<CommandList>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search options..."
className="h-9"
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList className="max-h-[200px] overflow-y-auto">
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup>
{safeOptions.map((option) => (
{filteredOptions.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
toggleValue(option.value);
}}
value={option.value}
onSelect={handleSelect}
>
<div className="flex items-center">
<div className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
safeSelectedValues.includes(option.value)
? "bg-primary border-primary"
: "opacity-50"
)}>
{safeSelectedValues.includes(option.value) && (
<Check className="h-3 w-3 text-primary-foreground" />
<Check
className={cn(
"mr-2 h-4 w-4",
value.includes(option.value) ? "opacity-100" : "opacity-0"
)}
</div>
/>
{option.label}
</div>
</CommandItem>
@@ -226,8 +218,8 @@ const MultiInputCell = <T extends string>({
<div className="w-full">
{isMultiline ? (
<Textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
value={value.join(separator)}
onChange={handleInputChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={`Enter values separated by ${separator}`}
@@ -237,44 +229,28 @@ const MultiInputCell = <T extends string>({
hasErrors ? "border-destructive" : ""
)}
/>
) : (
isEditing ? (
<Input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={`Enter values separated by ${separator}`}
autoFocus
className={cn(
outlineClass,
hasErrors ? "border-destructive" : ""
)}
/>
) : (
<div
onClick={handleFocus}
className={cn(
"px-3 py-2 h-10 rounded-md text-sm w-full cursor-text flex items-center overflow-hidden",
"flex h-9 w-full rounded-md border px-3 py-1 text-sm",
"cursor-text truncate", // Add truncate for text overflow
outlineClass,
hasErrors ? "border-destructive" : "border-input"
hasErrors ? "border-destructive" : "",
"overflow-hidden items-center"
)}
onClick={handleFocus}
>
{inputValue ?
<div className="truncate">
{isPrice ?
getDisplayValues().join(separator + ' ') :
inputValue
}
</div> :
<span className="text-muted-foreground">{`Enter values separated by ${separator}`}</span>
}
</div>
)
{value.length > 0 ? getDisplayValues().join(`, `) : (
<span className="text-muted-foreground">
{`Enter values separated by ${separator}`}
</span>
)}
</div>
)
}
)}
</div>
);
};
export default MultiInputCell
MultiInputCell.displayName = 'MultiInputCell';
export default React.memo(MultiInputCell);

View File

@@ -24,8 +24,8 @@ export type SelectOption = {
interface SelectCellProps<T extends string> {
field: Field<T>
value: any
onChange: (value: any) => void
value: string
onChange: (value: string) => void
onStartEdit?: () => void
onEndEdit?: () => void
hasErrors?: boolean

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react'
import { useState, useCallback, useRef } from 'react'
interface UpcValidationResult {
error?: boolean
@@ -6,63 +6,118 @@ interface UpcValidationResult {
data?: Record<string, any>
}
interface ValidationState {
validatingCells: Set<string>; // Using rowIndex-fieldKey as identifier
itemNumbers: Map<number, string>; // Using rowIndex as key
}
export const useUpcValidation = () => {
const [validatingUpcRows, setValidatingUpcRows] = useState<number[]>([])
// Use a ref for validation state to avoid triggering re-renders
const validationStateRef = useRef<ValidationState>({
validatingCells: new Set(),
itemNumbers: new Map()
});
// Mock API call for UPC validation
// In a real implementation, you would call an actual API
const mockUpcValidationApi = async (upcValue: string): Promise<UpcValidationResult> => {
// Simulate API call delay
await new Promise(resolve => setTimeout(resolve, 500))
// Use state only for forcing re-renders of specific cells
const [validatingCellKeys, setValidatingCellKeys] = useState<Set<string>>(new Set());
const [itemNumberUpdates, setItemNumberUpdates] = useState<Map<number, string>>(new Map());
// Validate UPC format
if (!/^\d{12,14}$/.test(upcValue)) {
// Helper to create cell key
const getCellKey = (rowIndex: number, fieldKey: string) => `${rowIndex}-${fieldKey}`;
// Start validating a cell
const startValidatingCell = useCallback((rowIndex: number, fieldKey: string) => {
const cellKey = getCellKey(rowIndex, fieldKey);
validationStateRef.current.validatingCells.add(cellKey);
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
}, []);
// Stop validating a cell
const stopValidatingCell = useCallback((rowIndex: number, fieldKey: string) => {
const cellKey = getCellKey(rowIndex, fieldKey);
validationStateRef.current.validatingCells.delete(cellKey);
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
}, []);
// Update item number
const updateItemNumber = useCallback((rowIndex: number, itemNumber: string) => {
validationStateRef.current.itemNumbers.set(rowIndex, itemNumber);
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
}, []);
// Check if a specific cell is being validated
const isValidatingCell = useCallback((rowIndex: number, fieldKey: string): boolean => {
return validationStateRef.current.validatingCells.has(getCellKey(rowIndex, fieldKey));
}, []);
// Get item number for a row
const getItemNumber = useCallback((rowIndex: number): string | undefined => {
return validationStateRef.current.itemNumbers.get(rowIndex);
}, []);
// Validate a UPC value
const validateUpc = useCallback(async (
upcValue: string,
rowIndex: number,
supplier: string
): Promise<UpcValidationResult> => {
// Start validating UPC and item number cells
startValidatingCell(rowIndex, 'upc');
startValidatingCell(rowIndex, 'item_number');
try {
// Call the UPC validation API
const response = await fetch(`/api/import/check-upc-and-generate-sku?upc=${encodeURIComponent(upcValue)}&supplierId=${encodeURIComponent(supplier)}`);
if (response.status === 409) {
const errorData = await response.json();
return {
error: true,
message: 'Invalid UPC format. UPC should be 12-14 digits.'
}
message: `UPC already exists (${errorData.existingItemNumber})`,
data: errorData
};
}
// Mock successful validation
// In a real implementation, this would return data from the API
if (!response.ok) {
throw new Error(`API error (${response.status})`);
}
const result = await response.json();
if (result.success && result.itemNumber) {
// Update item number in our state
updateItemNumber(rowIndex, result.itemNumber);
return {
error: false,
data: {
item_number: `ITEM-${upcValue.substring(0, 6)}`,
// Add any other fields that would be returned by the UPC validation
}
itemNumber: result.itemNumber,
...result
}
};
}
// Validate a UPC value
const validateUpc = useCallback(async (upcValue: string, rowIndex: number): Promise<UpcValidationResult> => {
// Add row to validating state
setValidatingUpcRows(prev => [...prev, rowIndex])
try {
// Call the UPC validation API (mock for now)
const result = await mockUpcValidationApi(upcValue)
return result
return {
error: true,
message: 'Invalid response from server'
};
} catch (error) {
console.error('Error validating UPC:', error)
console.error('Error validating UPC:', error);
return {
error: true,
message: 'Failed to validate UPC'
}
};
} finally {
// Remove row from validating state
setValidatingUpcRows(prev => prev.filter(idx => idx !== rowIndex))
// Stop validating both cells
stopValidatingCell(rowIndex, 'upc');
stopValidatingCell(rowIndex, 'item_number');
}
}, [])
// Check if a row is currently being validated
const isValidatingUpc = useCallback((rowIndex: number): boolean => {
return validatingUpcRows.includes(rowIndex)
}, [validatingUpcRows])
}, [startValidatingCell, stopValidatingCell, updateItemNumber]);
return {
validateUpc,
isValidatingUpc,
validatingUpcRows
isValidatingCell,
getItemNumber,
itemNumbers: itemNumberUpdates,
validatingCells: validatingCellKeys
}
}