Validate step - fix memoization and reduce unnecessary re-renders
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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,
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
size: fieldWidth,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
}, (prev, next) => {
|
||||||
|
// Compare row data
|
||||||
|
const prevRowStr = JSON.stringify(prev.row);
|
||||||
|
const nextRowStr = JSON.stringify(next.row);
|
||||||
|
if (prevRowStr !== nextRowStr) return false;
|
||||||
|
|
||||||
|
// Compare validation errors for this row
|
||||||
|
const prevErrors = prev.validationErrors.get(prev.rowIndex);
|
||||||
|
const nextErrors = next.validationErrors.get(next.rowIndex);
|
||||||
|
if (JSON.stringify(prevErrors) !== JSON.stringify(nextErrors)) return false;
|
||||||
|
|
||||||
|
// Compare validation state for this row's cells
|
||||||
|
const prevValidatingCells = Array.from(prev.validatingCells)
|
||||||
|
.filter(key => key.startsWith(`${prev.rowIndex}-`));
|
||||||
|
const nextValidatingCells = Array.from(next.validatingCells)
|
||||||
|
.filter(key => key.startsWith(`${next.rowIndex}-`));
|
||||||
|
if (JSON.stringify(prevValidatingCells) !== JSON.stringify(nextValidatingCells)) return false;
|
||||||
|
|
||||||
|
// Compare item numbers for this row
|
||||||
|
const prevItemNumber = prev.itemNumbers.get(prev.rowIndex);
|
||||||
|
const nextItemNumber = next.itemNumbers.get(next.rowIndex);
|
||||||
|
if (prevItemNumber !== nextItemNumber) return false;
|
||||||
|
|
||||||
|
// Compare selection state
|
||||||
|
if (prev.isSelected !== next.isSelected) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
return [selectionColumn, templateColumn, ...fieldColumns];
|
MemoizedRow.displayName = 'MemoizedRow';
|
||||||
}, [
|
|
||||||
columnHelper,
|
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;
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user