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

View File

@@ -1,272 +1,238 @@
import { useState, useCallback, useMemo, memo } from 'react' import React from 'react'
import { Field } from '../../../types' import { Field } from '../../../types'
import { Loader2 } from 'lucide-react' import { Loader2, AlertCircle } 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 { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, 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 = { type ErrorObject = {
message: string; message: string;
level: string; level: string;
source?: string; source?: string;
} }
/** // Memoized validation icon component
* ValidationIcon - Renders an appropriate icon based on error level const ValidationIcon = React.memo(({ error }: { error: ErrorObject }) => (
*/
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 (
<TooltipProvider> <TooltipProvider>
<Tooltip delayDuration={200}> <Tooltip delayDuration={200}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="cursor-help">{icon}</div> <div className="cursor-help">
<AlertCircle className="h-4 w-4 text-red-500" />
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-[300px] text-wrap break-words"> <TooltipContent className="max-w-[300px] text-wrap break-words">
<p>{error.message}</p> <p>{error.message}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </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 ValidationIcon.displayName = 'ValidationIcon';
const ErrorDisplay = memo(({ errors, isFocused }: { errors: ErrorObject[], isFocused: boolean }) => {
if (!errors || errors.length === 0) return null;
return ( // Separate component for item number cells to ensure they update independently
<> const ItemNumberCell = React.memo(({
<div className="absolute right-1 top-1/2 -translate-y-1/2"> value,
<ValidationIcon error={errors[0]} /> itemNumber,
</div> isValidating,
width
{isFocused && ( }: {
<div className="text-xs text-destructive p-1 mt-1 bg-destructive/5 rounded-sm"> value: any,
{errors.map((error, i) => ( itemNumber?: string,
<div key={i} className="py-0.5">{error.message}</div> 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> </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 ItemNumberCell.displayName = 'ItemNumberCell';
const ValidationCell = memo(<T extends string>(props: ValidationCellProps<T>) => {
const { // Memoized base cell content component
const BaseCellContent = React.memo(({
field, field,
value, value,
onChange, onChange,
errors, hasErrors,
isValidatingUpc = false, options = []
fieldKey, }: {
options } = props; 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 if (fieldType === 'select') {
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 {
return ( return (
<SelectCell <SelectCell
field={field} field={field}
value={value} value={value}
onChange={onChange} onChange={onChange}
onStartEdit={handleStartEdit}
onEndEdit={handleEndEdit}
hasErrors={hasErrors}
options={options} 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 if (fieldType === 'multi-select' || fieldType === 'multi-input') {
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':
return ( return (
<MultiInputCell <MultiInputCell
field={field} field={field}
value={value} value={value}
onChange={onChange} onChange={onChange}
onStartEdit={handleStartEdit} options={options}
onEndEdit={handleEndEdit}
hasErrors={hasErrors} 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 ( return (
<InputCell <InputCell
field={field} field={field}
value={value} value={value}
onChange={onChange} onChange={onChange}
onStartEdit={handleStartEdit}
onEndEdit={handleEndEdit}
hasErrors={hasErrors} hasErrors={hasErrors}
/> />
); );
} }, (prev, next) => {
} catch (error) {
console.error(`Error rendering cell of type ${fieldType}:`, error);
return ( return (
<div className="p-2 text-destructive"> prev.value === next.value &&
Error rendering field prev.hasErrors === next.hasErrors &&
</div> prev.field === next.field &&
); JSON.stringify(prev.options) === JSON.stringify(next.options)
}
}, [
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>
); );
}); });
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]); }, [data, rowSelection, setData, setRowSelection]);
// Enhanced ValidationTable component that's aware of item numbers // Enhanced ValidationTable component that's aware of item numbers
const EnhancedValidationTable = useCallback(({ const EnhancedValidationTable = useCallback((props: React.ComponentProps<typeof ValidationTable>) => {
data, // Create validatingCells set from validatingUpcRows
...props const validatingCells = useMemo(() => {
}: React.ComponentProps<typeof ValidationTable<T>>) => { 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 // Merge the item numbers with the data for display purposes only
const enhancedData = useMemo(() => { 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 // 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]) { if (itemNumbers[index]) {
return { ...row, item_number: itemNumbers[index] }; return { ...row, item_number: itemNumbers[index] };
} }
return row; return row;
}); });
}, [data]); }, [props.data]);
return <ValidationTable<T> data={enhancedData} {...props} />; return (
}, [itemNumbers]); <ValidationTable
{...props}
data={enhancedData}
validatingCells={validatingCells}
itemNumbers={itemNumbersMap}
/>
);
}, [itemNumbers, validatingUpcRows]);
// Memoize the ValidationTable to prevent unnecessary re-renders // Memoize the ValidationTable to prevent unnecessary re-renders
const renderValidationTable = useMemo(() => ( const renderValidationTable = useMemo(() => (
<EnhancedValidationTable <EnhancedValidationTable
data={filteredData} data={filteredData}
fields={validationState.fields} fields={validationState.fields}
updateRow={enhancedUpdateRow} updateRow={(rowIndex: number, key: string, value: any) =>
enhancedUpdateRow(rowIndex, key as T, value)
}
rowSelection={rowSelection} rowSelection={rowSelection}
setRowSelection={setRowSelection} setRowSelection={setRowSelection}
validationErrors={validationErrors} validationErrors={validationErrors}
@@ -587,6 +609,9 @@ const ValidationContainer = <T extends string>({
rowSublines={rowSublines} rowSublines={rowSublines}
isLoadingLines={isLoadingLines} isLoadingLines={isLoadingLines}
isLoadingSublines={isLoadingSublines} isLoadingSublines={isLoadingSublines}
upcValidationResults={new Map(Object.entries(itemNumbers).map(([key, value]) => [parseInt(key), { itemNumber: value }]))}
validatingCells={new Set()}
itemNumbers={new Map()}
/> />
), [ ), [
EnhancedValidationTable, EnhancedValidationTable,
@@ -605,7 +630,8 @@ const ValidationContainer = <T extends string>({
rowProductLines, rowProductLines,
rowSublines, rowSublines,
isLoadingLines, isLoadingLines,
isLoadingSublines isLoadingSublines,
itemNumbers
]); ]);
return ( 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, useReactTable,
getCoreRowModel, getCoreRowModel,
flexRender, flexRender,
createColumnHelper, RowSelectionState,
RowSelectionState} from '@tanstack/react-table' ColumnDef
} from '@tanstack/react-table'
import { Fields, Field } from '../../../types' import { Fields, Field } from '../../../types'
import { RowData, Template } from '../hooks/useValidationState' import { RowData, Template } from '../hooks/useValidationState'
import { Checkbox } from '@/components/ui/checkbox'
import ValidationCell from './ValidationCell' import ValidationCell from './ValidationCell'
import { useRsi } from '../../../hooks/useRsi' import { useRsi } from '../../../hooks/useRsi'
import SearchableTemplateSelect from './SearchableTemplateSelect' 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 // Define a simple Error type locally to avoid import issues
type ErrorType = { type ErrorType = {
@@ -36,169 +37,142 @@ interface ValidationTableProps<T extends string> {
rowSublines?: Record<string, any[]> rowSublines?: Record<string, any[]>
isLoadingLines?: Record<string, boolean> isLoadingLines?: Record<string, boolean>
isLoadingSublines?: Record<string, boolean> isLoadingSublines?: Record<string, boolean>
upcValidationResults: Map<number, { itemNumber: string }>
validatingCells: Set<string>
itemNumbers: Map<number, string>
[key: string]: any [key: string]: any
} }
// Memoized cell component to prevent unnecessary re-renders // Make Field type mutable for internal use
const MemoizedCell = React.memo( type MutableField<T extends string> = {
({ -readonly [K in keyof Field<T>]: Field<T>[K] extends readonly (infer U)[] ? U[] : Field<T>[K]
rowIndex, }
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, field,
value, value,
errors, rowIndex,
isValidatingUpc,
fieldOptions,
isOptionsLoading,
updateRow, updateRow,
columnId validationErrors,
}: { validatingCells,
rowIndex: number itemNumbers,
field: Field<any> width
value: any }: MemoizedCellProps) => {
errors: ErrorType[] const rowErrors = validationErrors.get(rowIndex) || {};
isValidatingUpc: (rowIndex: number) => boolean const fieldErrors = rowErrors[String(field.key)] || [];
fieldOptions?: any[] const isValidating = validatingCells.has(`${rowIndex}-${field.key}`);
isOptionsLoading?: boolean const options = field.fieldType.type === 'select' || field.fieldType.type === 'multi-select'
updateRow: (rowIndex: number, key: any, value: any) => void ? Array.from(field.fieldType.options || [])
columnId: string : [];
}) => {
const handleChange = (newValue: any) => {
updateRow(rowIndex, columnId, newValue);
};
return ( return (
<ValidationCell <ValidationCell
value={value}
field={field} field={field}
onChange={handleChange} value={value}
errors={errors || []} onChange={(newValue) => updateRow(rowIndex, field.key, newValue)}
isValidatingUpc={isValidatingUpc(rowIndex)} errors={fieldErrors}
fieldKey={columnId} isValidating={isValidating}
options={fieldOptions} fieldKey={String(field.key)}
isLoading={isOptionsLoading} options={options}
itemNumber={itemNumbers.get(rowIndex)}
width={width}
rowIndex={rowIndex}
/> />
); );
}, }, (prev, next) => {
// Custom comparison function for the memo const fieldKey = String(prev.field.key);
(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)
);
}
);
// Memoized template cell component // For item_number fields, only update if the item number or validation state changes
const MemoizedTemplateCell = React.memo( if (fieldKey === 'item_number') {
({ const prevItemNumber = prev.itemNumbers.get(prev.rowIndex);
rowIndex, const nextItemNumber = next.itemNumbers.get(next.rowIndex);
templateValue, const prevValidating = prev.validatingCells.has(`${prev.rowIndex}-item_number`);
templates, const nextValidating = next.validatingCells.has(`${next.rowIndex}-item_number`);
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]);
};
return ( return (
<SearchableTemplateSelect prevItemNumber === nextItemNumber &&
templates={templates} prevValidating === nextValidating &&
value={templateValue || ''} prev.value === next.value
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
); );
} }
);
const ValidationTable = <T extends string>({ // For UPC fields, only update if the value or validation state changes
data, 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, fields,
rowSelection,
setRowSelection,
updateRow, updateRow,
validationErrors, validationErrors,
isValidatingUpc, validatingCells,
filters, itemNumbers,
templates, options = {},
applyTemplate, rowIndex,
getTemplateDisplayText, isSelected
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;
return ( return (
<MemoizedTemplateCell <TableRow
rowIndex={rowIndex} key={row.__index || rowIndex}
templateValue={row.original.__template || null} data-state={isSelected && "selected"}
templates={templates} className={validationErrors.get(rowIndex) ? "bg-red-50/40" : "hover:bg-muted/50"}
applyTemplate={applyTemplate} >
getTemplateDisplayText={getTemplateDisplayText} {fields.map((field) => {
/> if (field.disabled) return null;
);
},
size: 200,
});
// 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 || ( const fieldWidth = field.width || (
field.fieldType.type === "checkbox" ? 80 : field.fieldType.type === "checkbox" ? 80 :
field.fieldType.type === "select" ? 150 : field.fieldType.type === "select" ? 150 :
@@ -208,167 +182,208 @@ const ValidationTable = <T extends string>({
150 150
); );
return columnHelper.accessor( const isValidating = validatingCells.has(`${rowIndex}-${field.key}`);
(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>;
return ( 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} rowIndex={rowIndex}
field={typedField} itemNumber={itemNumbers.get(rowIndex)}
value={value}
errors={errors}
isValidatingUpc={isValidatingUpc}
fieldOptions={fieldOptions}
isOptionsLoading={isOptionsLoading}
updateRow={updateRow}
columnId={column.id}
/> />
); );
} catch (error) { })}
console.error(`Error rendering cell for column ${column.id}:`, error); </TableRow>
return (
<div className="p-2 text-destructive text-sm">
Error rendering cell
</div>
); );
} }, (prev, next) => {
}, // Compare row data
size: fieldWidth, const prevRowStr = JSON.stringify(prev.row);
} const nextRowStr = JSON.stringify(next.row);
); if (prevRowStr !== nextRowStr) return false;
});
return [selectionColumn, templateColumn, ...fieldColumns]; // Compare validation errors for this row
}, [ const prevErrors = prev.validationErrors.get(prev.rowIndex);
columnHelper, 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;
});
MemoizedRow.displayName = 'MemoizedRow';
const ValidationTable = <T extends string>({
data,
fields, fields,
rowSelection,
setRowSelection,
updateRow,
validationErrors,
filters,
templates, templates,
applyTemplate, applyTemplate,
getTemplateDisplayText, getTemplateDisplayText,
rowProductLines, validatingCells,
rowSublines, itemNumbers
isLoadingLines, }: ValidationTableProps<T>) => {
isLoadingSublines, const { translations } = useRsi<T>();
validationErrors,
isValidatingUpc, // Memoize the template column
updateRow 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({ const table = useReactTable({
data, data,
columns, columns,
state: { getCoreRowModel: getCoreRowModel(),
rowSelection, state: { rowSelection },
},
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.__index || String(row.index)
}); });
// Apply filters to rows if needed // Don't render if no data
const filteredRows = useMemo(() => { if (data.length === 0) {
let rows = table.getRowModel().rows; return (
<div className="flex items-center justify-center h-full">
if (filters?.showErrorsOnly) { <p className="text-muted-foreground">
rows = rows.filter(row => { {filters?.showErrorsOnly
const rowIndex = row.index; ? translations.validationStep.noRowsMessageWhenFiltered || "No rows with errors"
return validationErrors.has(rowIndex) && : translations.validationStep.noRowsMessage || "No data to display"}
Object.values(validationErrors.get(rowIndex) || {}).some(errors => errors.length > 0); </p>
}); </div>
);
} }
return rows;
}, [table, filters, validationErrors]);
return ( return (
<div className="h-full flex flex-col"> <Table>
<div className="overflow-auto flex-1"> <TableHeader>
<table className="w-full border-separate border-spacing-0" style={{ tableLayout: 'fixed' }}> <TableRow>
<thead className="sticky top-0 z-10 bg-background border-b h-5"> {table.getFlatHeaders().map((header) => (
<tr> <TableHead
{table.getHeaderGroups()[0].headers.map((header) => (
<th
key={header.id} key={header.id}
style={{ style={{
width: `${header.getSize()}px`, 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 {flexRender(header.column.columnDef.header, header.getContext())}
? null </TableHead>
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))} ))}
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{filteredRows.length ? ( {table.getRowModel().rows.map((row) => (
filteredRows.map((row) => ( <TableRow
<tr
key={row.id} key={row.id}
data-state={row.getIsSelected() ? "selected" : undefined} data-state={row.getIsSelected() && "selected"}
className={validationErrors.has(row.index) ? "bg-red-50/30" : ""} className={validationErrors.get(row.index) ? "bg-red-50/40" : "hover:bg-muted/50"}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td flexRender(cell.column.columnDef.cell, cell.getContext())
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>
))} ))}
</tr> </TableRow>
)) ))}
) : ( </TableBody>
<tr> </Table>
<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>
); );
}; };
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 { Field } from '../../../../types'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
@@ -25,34 +25,26 @@ const InputCell = <T extends string>({
isMultiline = false, isMultiline = false,
isPrice = false isPrice = false
}: InputCellProps<T>) => { }: InputCellProps<T>) => {
const [inputValue, setInputValue] = useState('')
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState('')
// Initialize input value
useEffect(() => {
if (value !== undefined && value !== null) {
setInputValue(String(value))
} else {
setInputValue('')
}
}, [value])
// Handle focus event // Handle focus event
const handleFocus = useCallback(() => { const handleFocus = useCallback(() => {
setIsEditing(true) setIsEditing(true)
setEditValue(value !== undefined && value !== null ? String(value) : '')
onStartEdit?.() onStartEdit?.()
}, [onStartEdit]) }, [value, onStartEdit])
// Handle blur event // Handle blur event
const handleBlur = useCallback(() => { const handleBlur = useCallback(() => {
setIsEditing(false) setIsEditing(false)
// Format the value for storage (remove formatting like $ for price) // Format the value for storage (remove formatting like $ for price)
let processedValue = inputValue let processedValue = editValue
if (isPrice) { if (isPrice) {
// Remove any non-numeric characters except decimal point // 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 // Parse as float and format to 2 decimal places to ensure valid number
const numValue = parseFloat(processedValue) const numValue = parseFloat(processedValue)
@@ -63,21 +55,21 @@ const InputCell = <T extends string>({
onChange(processedValue) onChange(processedValue)
onEndEdit?.() onEndEdit?.()
}, [inputValue, onChange, onEndEdit, isPrice]) }, [editValue, onChange, onEndEdit, isPrice])
// Format price value for display // Format price value for display
const getDisplayValue = useCallback(() => { const getDisplayValue = useCallback(() => {
if (!isPrice || !inputValue) return inputValue if (!isPrice || !value) return value
// Extract numeric part // Extract numeric part
const numericValue = inputValue.replace(/[^\d.]/g, '') const numericValue = String(value).replace(/[^\d.]/g, '')
// Parse as float and format with dollar sign // Parse as float and format with dollar sign
const numValue = parseFloat(numericValue) const numValue = parseFloat(numericValue)
if (isNaN(numValue)) return inputValue if (isNaN(numValue)) return value
return `$${numValue.toFixed(2)}` return `$${numValue.toFixed(2)}`
}, [inputValue, isPrice]) }, [value, isPrice])
// Add outline even when not in focus // Add outline even when not in focus
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0" const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
@@ -86,8 +78,8 @@ const InputCell = <T extends string>({
<div className="w-full"> <div className="w-full">
{isMultiline ? ( {isMultiline ? (
<Textarea <Textarea
value={inputValue} value={isEditing ? editValue : (value ?? '')}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setEditValue(e.target.value)}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
className={cn( className={cn(
@@ -100,8 +92,8 @@ const InputCell = <T extends string>({
isEditing ? ( isEditing ? (
<Input <Input
type="text" type="text"
value={inputValue} value={editValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setEditValue(e.target.value)}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
autoFocus autoFocus
@@ -119,7 +111,7 @@ const InputCell = <T extends string>({
hasErrors ? "border-destructive" : "border-input" hasErrors ? "border-destructive" : "border-input"
)} )}
> >
{isPrice ? getDisplayValue() : (inputValue)} {isPrice ? getDisplayValue() : (value ?? '')}
</div> </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 { Field } from '../../../../types'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
@@ -9,22 +9,35 @@ import { Button } from '@/components/ui/button'
import { Check, ChevronsUpDown, X } from 'lucide-react' import { Check, ChevronsUpDown, X } from 'lucide-react'
import { Badge } from '@/components/ui/badge' 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> { interface MultiInputCellProps<T extends string> {
field: Field<T> field: Field<T>
value: any value: string[]
onChange: (value: any) => void onChange: (value: string[]) => void
onStartEdit?: () => void onStartEdit?: () => void
onEndEdit?: () => void onEndEdit?: () => void
hasErrors?: boolean hasErrors?: boolean
separator?: string separator?: string
isMultiline?: boolean isMultiline?: boolean
isPrice?: boolean isPrice?: boolean
options?: readonly { label: string; value: string }[] options?: readonly FieldOption[]
} }
const MultiInputCell = <T extends string>({ const MultiInputCell = <T extends string>({
field, field,
value, value = [],
onChange, onChange,
onStartEdit, onStartEdit,
onEndEdit, onEndEdit,
@@ -35,106 +48,117 @@ const MultiInputCell = <T extends string>({
options: providedOptions options: providedOptions
}: MultiInputCellProps<T>) => { }: MultiInputCellProps<T>) => {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [inputValue, setInputValue] = useState('') const [searchQuery, setSearchQuery] = useState("")
const [selectedValues, setSelectedValues] = useState<string[]>([])
const [isEditing, setIsEditing] = useState(false)
// Check if field is a multi-select field // Memoize field options to prevent unnecessary recalculations
const isMultiSelect = field.fieldType.type === 'multi-select' const selectOptions = useMemo(() => {
const fieldOptions = isMultiSelect && field.fieldType.options const fieldType = field.fieldType;
? field.fieldType.options const fieldOptions = fieldType &&
: undefined (fieldType.type === 'select' || fieldType.type === 'multi-select') &&
fieldType.options ?
fieldType.options :
[];
// Convert options to a regular array to avoid issues with readonly arrays // Use provided options or field options, ensuring they have the correct shape
const options = providedOptions const availableOptions = (providedOptions || fieldOptions || []).map(option => ({
? Array.from(providedOptions) label: option.label || String(option.value),
: (fieldOptions ? Array.from(fieldOptions) : []) value: String(option.value)
}));
// Initialize input value and selected values // Add default option if no options available
useEffect(() => { if (availableOptions.length === 0) {
if (value !== undefined && value !== null) { availableOptions.push({ label: 'No options available', value: '' });
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([])
} }
}, [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 // Handle focus
const handleFocus = useCallback(() => { const handleFocus = useCallback(() => {
setIsEditing(true) if (onStartEdit) onStartEdit();
onStartEdit?.() }, [onStartEdit]);
}, [onStartEdit])
// Handle blur // Handle blur
const handleBlur = useCallback(() => { const handleBlur = useCallback(() => {
setIsEditing(false) if (onEndEdit) onEndEdit();
}, [onEndEdit]);
// Format all values and join with separator // Handle direct input changes
let processedValues = selectedValues const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const newValue = e.target.value;
if (isPrice) { onChange(newValue.split(separator).map(v => v.trim()).filter(Boolean));
// Format all values as prices }, [separator, onChange]);
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))
}, [])
// Format display values for price // Format display values for price
const getDisplayValues = useCallback(() => { const getDisplayValues = useCallback(() => {
if (!isPrice) return selectedValues if (!isPrice) return selectedValues.map(v => v.label);
return selectedValues.map(val => { return selectedValues.map(v => {
const numValue = parseFloat(val.replace(/[^\d.]/g, '')) const numValue = parseFloat(v.value.replace(/[^\d.]/g, ''))
return !isNaN(numValue) ? `$${numValue.toFixed(2)}` : val return !isNaN(numValue) ? `$${numValue.toFixed(2)}` : v.value
}) });
}, [selectedValues, isPrice]) }, [selectedValues, isPrice]);
// Add outline even when not in focus // Add outline even when not in focus
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0" const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
// If we have a multi-select field with options, use command UI // If we have a multi-select field with options, use command UI
if (isMultiSelect && options.length > 0) { if (field.fieldType.type === 'multi-select' && selectOptions.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);
return ( 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}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
@@ -142,72 +166,40 @@ const MultiInputCell = <T extends string>({
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className={cn( className={cn(
"w-full justify-between font-normal min-h-10", "w-full justify-between font-normal",
outlineClass, !value.length && "text-muted-foreground",
"text-left", hasErrors && "border-red-500"
hasErrors ? "border-destructive" : ""
)} )}
onClick={() => { onClick={handleFocus}
setOpen(!open)
handleFocus()
}}
> >
<div className="flex flex-wrap gap-1 max-w-[90%]"> {value.length === 0 ? "Select..." : `${value.length} selected`}
{safeSelectedValues.length > 0 ? ( <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
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" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-full p-0" align="start"> <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command> <Command shouldFilter={false}>
<CommandInput placeholder="Search options..." /> <CommandInput
<CommandList> placeholder="Search options..."
className="h-9"
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList className="max-h-[200px] overflow-y-auto">
<CommandEmpty>No options found.</CommandEmpty> <CommandEmpty>No options found.</CommandEmpty>
<CommandGroup> <CommandGroup>
{safeOptions.map((option) => ( {filteredOptions.map((option) => (
<CommandItem <CommandItem
key={option.value} key={option.value}
value={option.label} value={option.value}
onSelect={() => { onSelect={handleSelect}
toggleValue(option.value);
}}
> >
<div className="flex items-center"> <div className="flex items-center">
<div className={cn( <Check
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border", className={cn(
safeSelectedValues.includes(option.value) "mr-2 h-4 w-4",
? "bg-primary border-primary" value.includes(option.value) ? "opacity-100" : "opacity-0"
: "opacity-50"
)}>
{safeSelectedValues.includes(option.value) && (
<Check className="h-3 w-3 text-primary-foreground" />
)} )}
</div> />
{option.label} {option.label}
</div> </div>
</CommandItem> </CommandItem>
@@ -226,8 +218,8 @@ const MultiInputCell = <T extends string>({
<div className="w-full"> <div className="w-full">
{isMultiline ? ( {isMultiline ? (
<Textarea <Textarea
value={inputValue} value={value.join(separator)}
onChange={(e) => setInputValue(e.target.value)} onChange={handleInputChange}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
placeholder={`Enter values separated by ${separator}`} placeholder={`Enter values separated by ${separator}`}
@@ -237,44 +229,28 @@ const MultiInputCell = <T extends string>({
hasErrors ? "border-destructive" : "" 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 <div
onClick={handleFocus}
className={cn( 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, outlineClass,
hasErrors ? "border-destructive" : "border-input" hasErrors ? "border-destructive" : "",
"overflow-hidden items-center"
)} )}
onClick={handleFocus}
> >
{inputValue ? {value.length > 0 ? getDisplayValues().join(`, `) : (
<div className="truncate"> <span className="text-muted-foreground">
{isPrice ? {`Enter values separated by ${separator}`}
getDisplayValues().join(separator + ' ') : </span>
inputValue
}
</div> :
<span className="text-muted-foreground">{`Enter values separated by ${separator}`}</span>
}
</div>
)
)} )}
</div> </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> { interface SelectCellProps<T extends string> {
field: Field<T> field: Field<T>
value: any value: string
onChange: (value: any) => void onChange: (value: string) => void
onStartEdit?: () => void onStartEdit?: () => void
onEndEdit?: () => void onEndEdit?: () => void
hasErrors?: boolean hasErrors?: boolean

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react' import { useState, useCallback, useRef } from 'react'
interface UpcValidationResult { interface UpcValidationResult {
error?: boolean error?: boolean
@@ -6,63 +6,118 @@ interface UpcValidationResult {
data?: Record<string, any> 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 = () => { 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 // Use state only for forcing re-renders of specific cells
// In a real implementation, you would call an actual API const [validatingCellKeys, setValidatingCellKeys] = useState<Set<string>>(new Set());
const mockUpcValidationApi = async (upcValue: string): Promise<UpcValidationResult> => { const [itemNumberUpdates, setItemNumberUpdates] = useState<Map<number, string>>(new Map());
// Simulate API call delay
await new Promise(resolve => setTimeout(resolve, 500))
// Validate UPC format // Helper to create cell key
if (!/^\d{12,14}$/.test(upcValue)) { 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 { return {
error: true, error: true,
message: 'Invalid UPC format. UPC should be 12-14 digits.' message: `UPC already exists (${errorData.existingItemNumber})`,
} data: errorData
};
} }
// Mock successful validation if (!response.ok) {
// In a real implementation, this would return data from the API 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 { return {
error: false, error: false,
data: { data: {
item_number: `ITEM-${upcValue.substring(0, 6)}`, itemNumber: result.itemNumber,
// Add any other fields that would be returned by the UPC validation ...result
}
} }
};
} }
// Validate a UPC value return {
const validateUpc = useCallback(async (upcValue: string, rowIndex: number): Promise<UpcValidationResult> => { error: true,
// Add row to validating state message: 'Invalid response from server'
setValidatingUpcRows(prev => [...prev, rowIndex]) };
try {
// Call the UPC validation API (mock for now)
const result = await mockUpcValidationApi(upcValue)
return result
} catch (error) { } catch (error) {
console.error('Error validating UPC:', error) console.error('Error validating UPC:', error);
return { return {
error: true, error: true,
message: 'Failed to validate UPC' message: 'Failed to validate UPC'
} };
} finally { } finally {
// Remove row from validating state // Stop validating both cells
setValidatingUpcRows(prev => prev.filter(idx => idx !== rowIndex)) stopValidatingCell(rowIndex, 'upc');
stopValidatingCell(rowIndex, 'item_number');
} }
}, []) }, [startValidatingCell, stopValidatingCell, updateItemNumber]);
// Check if a row is currently being validated
const isValidatingUpc = useCallback((rowIndex: number): boolean => {
return validatingUpcRows.includes(rowIndex)
}, [validatingUpcRows])
return { return {
validateUpc, validateUpc,
isValidatingUpc, isValidatingCell,
validatingUpcRows getItemNumber,
itemNumbers: itemNumberUpdates,
validatingCells: validatingCellKeys
} }
} }