Validate step - fix memoization and reduce unnecessary re-renders
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
},
|
||||
size: fieldWidth,
|
||||
}
|
||||
);
|
||||
});
|
||||
}, (prev, next) => {
|
||||
// Compare row data
|
||||
const prevRowStr = JSON.stringify(prev.row);
|
||||
const nextRowStr = JSON.stringify(next.row);
|
||||
if (prevRowStr !== nextRowStr) return false;
|
||||
|
||||
return [selectionColumn, templateColumn, ...fieldColumns];
|
||||
}, [
|
||||
columnHelper,
|
||||
// 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;
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user