Improve copy down functionality with loading state and ability to select end cell instead of defaulting to the bottom
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import MultiSelectCell from './MultiSelectCell';
|
||||
|
||||
const BaseCellContent = ({ fieldType, field, value, onChange, options, hasErrors, className }) => {
|
||||
if (fieldType === 'multi-select' || fieldType === 'multi-input') {
|
||||
return (
|
||||
<MultiSelectCell
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
hasErrors={hasErrors}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default BaseCellContent;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import { Field } from '../../../types'
|
||||
import { Loader2, AlertCircle, ArrowDown } from 'lucide-react'
|
||||
import { Loader2, AlertCircle, ArrowDown, Check, X } from 'lucide-react'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -12,6 +12,29 @@ import SelectCell from './cells/SelectCell'
|
||||
import MultiSelectCell from './cells/MultiSelectCell'
|
||||
import { TableCell } from '@/components/ui/table'
|
||||
|
||||
// Context for copy down selection mode
|
||||
export const CopyDownContext = React.createContext<{
|
||||
isInCopyDownMode: boolean;
|
||||
sourceRowIndex: number | null;
|
||||
sourceFieldKey: string | null;
|
||||
targetRowIndex: number | null;
|
||||
setIsInCopyDownMode: (value: boolean) => void;
|
||||
setSourceRowIndex: (value: number | null) => void;
|
||||
setSourceFieldKey: (value: string | null) => void;
|
||||
setTargetRowIndex: (value: number | null) => void;
|
||||
handleCopyDownComplete: (sourceRowIndex: number, fieldKey: string, targetRowIndex: number) => void;
|
||||
}>({
|
||||
isInCopyDownMode: false,
|
||||
sourceRowIndex: null,
|
||||
sourceFieldKey: null,
|
||||
targetRowIndex: null,
|
||||
setIsInCopyDownMode: () => {},
|
||||
setSourceRowIndex: () => {},
|
||||
setSourceFieldKey: () => {},
|
||||
setTargetRowIndex: () => {},
|
||||
handleCopyDownComplete: () => {},
|
||||
});
|
||||
|
||||
// Define error object type
|
||||
type ErrorObject = {
|
||||
message: string;
|
||||
@@ -51,13 +74,15 @@ const BaseCellContent = React.memo(({
|
||||
value,
|
||||
onChange,
|
||||
hasErrors,
|
||||
options = []
|
||||
options = [],
|
||||
className = ''
|
||||
}: {
|
||||
field: Field<string>;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
hasErrors: boolean;
|
||||
options?: readonly any[];
|
||||
className?: string;
|
||||
}) => {
|
||||
// Get field type information
|
||||
const fieldType = typeof field.fieldType === 'string'
|
||||
@@ -82,6 +107,7 @@ const BaseCellContent = React.memo(({
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
hasErrors={hasErrors}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -94,6 +120,7 @@ const BaseCellContent = React.memo(({
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
hasErrors={hasErrors}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -119,6 +146,7 @@ const BaseCellContent = React.memo(({
|
||||
prev.value === next.value &&
|
||||
prev.hasErrors === next.hasErrors &&
|
||||
prev.field === next.field &&
|
||||
prev.className === next.className &&
|
||||
optionsEqual
|
||||
);
|
||||
});
|
||||
@@ -136,7 +164,8 @@ export interface ValidationCellProps {
|
||||
itemNumber?: string
|
||||
width: number
|
||||
rowIndex: number
|
||||
copyDown?: () => void
|
||||
copyDown?: (endRowIndex?: number) => void
|
||||
totalRows?: number
|
||||
}
|
||||
|
||||
// Add efficient error message extraction function
|
||||
@@ -223,7 +252,9 @@ const ItemNumberCell = React.memo(({
|
||||
errors = [],
|
||||
field,
|
||||
onChange,
|
||||
copyDown
|
||||
copyDown,
|
||||
rowIndex,
|
||||
totalRows = 0
|
||||
}: {
|
||||
value: any,
|
||||
itemNumber?: string,
|
||||
@@ -232,7 +263,9 @@ const ItemNumberCell = React.memo(({
|
||||
errors?: ErrorObject[],
|
||||
field: Field<string>,
|
||||
onChange: (value: any) => void,
|
||||
copyDown?: () => void
|
||||
copyDown?: (endRowIndex?: number) => void,
|
||||
rowIndex: number,
|
||||
totalRows?: number
|
||||
}) => {
|
||||
// If we have a value or itemNumber, ignore "required" errors
|
||||
const displayValue = itemNumber || value;
|
||||
@@ -248,10 +281,67 @@ const ItemNumberCell = React.memo(({
|
||||
[displayValue, errors]
|
||||
);
|
||||
|
||||
// Add state for hover on copy down button
|
||||
const [isCopyDownHovered, setIsCopyDownHovered] = React.useState(false);
|
||||
// Add state for hover on target row
|
||||
const [isTargetRowHovered, setIsTargetRowHovered] = React.useState(false);
|
||||
|
||||
// Get copy down context
|
||||
const copyDownContext = React.useContext(CopyDownContext);
|
||||
|
||||
// Handle copy down button click
|
||||
const handleCopyDownClick = () => {
|
||||
if (copyDown && totalRows > rowIndex + 1) {
|
||||
// Enter copy down mode
|
||||
copyDownContext.setIsInCopyDownMode(true);
|
||||
copyDownContext.setSourceRowIndex(rowIndex);
|
||||
copyDownContext.setSourceFieldKey('item_number');
|
||||
}
|
||||
};
|
||||
|
||||
// Check if this cell is the source of the current copy down operation
|
||||
const isSourceCell = copyDownContext.isInCopyDownMode &&
|
||||
copyDownContext.sourceRowIndex === rowIndex &&
|
||||
copyDownContext.sourceFieldKey === 'item_number';
|
||||
|
||||
// Check if this cell is in a row that can be a target for copy down
|
||||
const isInTargetRow = copyDownContext.isInCopyDownMode &&
|
||||
copyDownContext.sourceFieldKey === 'item_number' &&
|
||||
rowIndex > (copyDownContext.sourceRowIndex || 0);
|
||||
|
||||
// Check if this row is the currently selected target row
|
||||
const isSelectedTarget = isInTargetRow && rowIndex <= (copyDownContext.targetRowIndex || 0);
|
||||
|
||||
// Handle click on a potential target cell
|
||||
const handleTargetCellClick = () => {
|
||||
if (isInTargetRow && copyDownContext.sourceRowIndex !== null) {
|
||||
copyDownContext.handleCopyDownComplete(
|
||||
copyDownContext.sourceRowIndex,
|
||||
'item_number',
|
||||
rowIndex
|
||||
);
|
||||
}
|
||||
};
|
||||
//item_number fields
|
||||
return (
|
||||
<TableCell className="p-1 group relative" style={{ width: `${width}px`, minWidth: `${width}px`, maxWidth: `${width}px`, boxSizing: 'border-box' }}>
|
||||
<TableCell
|
||||
className="p-1 group relative"
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
minWidth: `${width}px`,
|
||||
maxWidth: `${width}px`,
|
||||
boxSizing: 'border-box',
|
||||
...(isSourceCell ? { backgroundColor: '#dbeafe', borderRadius: '0.375rem', padding: 0, boxShadow: '0 0 0 2px #3b82f6' } :
|
||||
isSelectedTarget ? { backgroundColor: '#bfdbfe', borderRadius: '0.375rem', padding: 0 } :
|
||||
isInTargetRow && isTargetRowHovered ? { backgroundColor: '#dbeafe', borderRadius: '0.375rem', padding: 0, cursor: 'pointer' } :
|
||||
isInTargetRow ? { borderRadius: '0.375rem', padding: 0, cursor: 'pointer' } : {})
|
||||
}}
|
||||
onClick={isInTargetRow ? handleTargetCellClick : undefined}
|
||||
onMouseEnter={isInTargetRow ? () => setIsTargetRowHovered(true) : undefined}
|
||||
onMouseLeave={isInTargetRow ? () => setIsTargetRowHovered(false) : undefined}
|
||||
>
|
||||
<div className={`relative ${hasError || isRequiredButEmpty ? 'border-red-500' : ''}`}>
|
||||
{shouldShowErrorIcon && (
|
||||
{shouldShowErrorIcon && !isInTargetRow && (
|
||||
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 z-20">
|
||||
<ValidationIcon error={{
|
||||
message: errorMessages,
|
||||
@@ -259,38 +349,69 @@ const ItemNumberCell = React.memo(({
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
{!shouldShowErrorIcon && copyDown && !isEmpty(displayValue) && (
|
||||
{!shouldShowErrorIcon && copyDown && !isEmpty(displayValue) && !copyDownContext.isInCopyDownMode && (
|
||||
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 z-20 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={copyDown}
|
||||
className="p-1 rounded-sm hover:bg-muted text-muted-foreground/50 hover:text-foreground"
|
||||
onClick={handleCopyDownClick}
|
||||
onMouseEnter={() => setIsCopyDownHovered(true)}
|
||||
onMouseLeave={() => setIsCopyDownHovered(false)}
|
||||
className="p-1 rounded-full hover:bg-blue-100 text-blue-500/70 hover:text-blue-600 transition-colors"
|
||||
aria-label="Copy value to rows below"
|
||||
>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Copy value to all cells below</p>
|
||||
<TooltipContent side="right">
|
||||
<div className="flex flex-col">
|
||||
<p className="font-medium">Copy value to rows below</p>
|
||||
<p className="text-xs text-muted-foreground">Click to select target rows</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
{isSourceCell && (
|
||||
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 z-20">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => copyDownContext.setIsInCopyDownMode(false)}
|
||||
className="p-1 rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 transition-colors"
|
||||
aria-label="Cancel copy down"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Cancel copy down</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
{isValidating ? (
|
||||
<div className={`flex items-center justify-center gap-2 border ${hasError || isRequiredButEmpty ? 'border-red-500' : 'border-input'} rounded-md h-10`}>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-foreground ml-2" />
|
||||
<span>{displayValue || ''}</span>
|
||||
<div className={`flex items-center justify-center gap-2 border ${hasError || isRequiredButEmpty ? 'border-red-500' : 'border-input'} rounded-sm px-2 py-1.5`}>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="truncate overflow-hidden">
|
||||
<div className={`truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}>
|
||||
<BaseCellContent
|
||||
field={field}
|
||||
value={displayValue}
|
||||
onChange={onChange}
|
||||
hasErrors={hasError || isRequiredButEmpty}
|
||||
options={(field.fieldType && typeof field.fieldType === 'object' && (field.fieldType as any).options) || []}
|
||||
className={isSourceCell || isSelectedTarget || isInTargetRow ? `${
|
||||
isSourceCell ? '!bg-blue-100 !border-blue-500 !rounded-md' :
|
||||
isSelectedTarget ? '!bg-blue-200 !border-blue-200 !rounded-md' :
|
||||
isInTargetRow ? 'hover:!bg-blue-100 !border-blue-200 !rounded-md' : ''
|
||||
}` : ''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -316,7 +437,17 @@ const ValidationCell = ({
|
||||
options = [],
|
||||
itemNumber,
|
||||
width,
|
||||
copyDown}: ValidationCellProps) => {
|
||||
copyDown,
|
||||
rowIndex,
|
||||
totalRows = 0}: ValidationCellProps) => {
|
||||
// Add state for hover on copy down button
|
||||
const [isCopyDownHovered, setIsCopyDownHovered] = React.useState(false);
|
||||
// Add state for hover on target row
|
||||
const [isTargetRowHovered, setIsTargetRowHovered] = React.useState(false);
|
||||
|
||||
// Get copy down context
|
||||
const copyDownContext = React.useContext(CopyDownContext);
|
||||
|
||||
// For item_number fields, use the specialized component
|
||||
if (fieldKey === 'item_number') {
|
||||
return (
|
||||
@@ -329,6 +460,8 @@ const ValidationCell = ({
|
||||
field={field}
|
||||
onChange={onChange}
|
||||
copyDown={copyDown}
|
||||
rowIndex={rowIndex}
|
||||
totalRows={totalRows}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -360,14 +493,59 @@ const ValidationCell = ({
|
||||
return { hasError, isRequiredButEmpty, shouldShowErrorIcon, errorMessages };
|
||||
}, [filteredErrors, value, errors]);
|
||||
|
||||
// Check if this is a multiline field
|
||||
// Handle copy down button click
|
||||
const handleCopyDownClick = () => {
|
||||
if (copyDown && totalRows > rowIndex + 1) {
|
||||
// Enter copy down mode
|
||||
copyDownContext.setIsInCopyDownMode(true);
|
||||
copyDownContext.setSourceRowIndex(rowIndex);
|
||||
copyDownContext.setSourceFieldKey(fieldKey);
|
||||
}
|
||||
};
|
||||
|
||||
// Check for price field
|
||||
// Check if this cell is the source of the current copy down operation
|
||||
const isSourceCell = copyDownContext.isInCopyDownMode &&
|
||||
copyDownContext.sourceRowIndex === rowIndex &&
|
||||
copyDownContext.sourceFieldKey === fieldKey;
|
||||
|
||||
// Check if this cell is in a row that can be a target for copy down
|
||||
const isInTargetRow = copyDownContext.isInCopyDownMode &&
|
||||
copyDownContext.sourceFieldKey === fieldKey &&
|
||||
rowIndex > (copyDownContext.sourceRowIndex || 0);
|
||||
|
||||
// Check if this row is the currently selected target row
|
||||
const isSelectedTarget = isInTargetRow && rowIndex <= (copyDownContext.targetRowIndex || 0);
|
||||
|
||||
// Handle click on a potential target cell
|
||||
const handleTargetCellClick = () => {
|
||||
if (isInTargetRow && copyDownContext.sourceRowIndex !== null && copyDownContext.sourceFieldKey !== null) {
|
||||
copyDownContext.handleCopyDownComplete(
|
||||
copyDownContext.sourceRowIndex,
|
||||
copyDownContext.sourceFieldKey,
|
||||
rowIndex
|
||||
);
|
||||
}
|
||||
};
|
||||
//normal selects, normal inputs, not item_number or multi-select
|
||||
return (
|
||||
<TableCell className="p-1 group relative" style={{ width: `${width}px`, minWidth: `${width}px`, maxWidth: `${width}px`, boxSizing: 'border-box' }}>
|
||||
<TableCell
|
||||
className="p-1 group relative"
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
minWidth: `${width}px`,
|
||||
maxWidth: `${width}px`,
|
||||
boxSizing: 'border-box',
|
||||
...(isSourceCell ? { backgroundColor: '#dbeafe', borderRadius: '0.375rem', padding: 0, boxShadow: '0 0 0 2px #3b82f6' } :
|
||||
isSelectedTarget ? { backgroundColor: '#bfdbfe', borderRadius: '0.375rem', padding: 0 } :
|
||||
isInTargetRow && isTargetRowHovered ? { backgroundColor: '#dbeafe', borderRadius: '0.375rem', padding: 0, cursor: 'pointer' } :
|
||||
isInTargetRow ? { borderRadius: '0.375rem', padding: 0, cursor: 'pointer' } : {})
|
||||
}}
|
||||
onClick={isInTargetRow ? handleTargetCellClick : undefined}
|
||||
onMouseEnter={isInTargetRow ? () => setIsTargetRowHovered(true) : undefined}
|
||||
onMouseLeave={isInTargetRow ? () => setIsTargetRowHovered(false) : undefined}
|
||||
>
|
||||
<div className={`relative ${hasError || isRequiredButEmpty ? 'border-red-500' : ''}`}>
|
||||
{shouldShowErrorIcon && (
|
||||
{shouldShowErrorIcon && !isInTargetRow && (
|
||||
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 z-20">
|
||||
<ValidationIcon error={{
|
||||
message: errorMessages,
|
||||
@@ -375,20 +553,46 @@ const ValidationCell = ({
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
{!shouldShowErrorIcon && copyDown && !isEmpty(value) && (
|
||||
{!shouldShowErrorIcon && copyDown && !isEmpty(value) && !copyDownContext.isInCopyDownMode && (
|
||||
<div className="absolute right-0.5 top-1/2 -translate-y-1/2 z-20 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={copyDown}
|
||||
className="p-1 rounded-full hover:bg-muted text-muted-foreground/50 hover:text-foreground"
|
||||
onClick={handleCopyDownClick}
|
||||
onMouseEnter={() => setIsCopyDownHovered(true)}
|
||||
onMouseLeave={() => setIsCopyDownHovered(false)}
|
||||
className="p-1 rounded-full hover:bg-blue-100 text-blue-500/70 hover:text-blue-600 transition-colors"
|
||||
aria-label="Copy value to rows below"
|
||||
>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Copy value to all cells below</p>
|
||||
<TooltipContent side="right">
|
||||
<div className="flex flex-col">
|
||||
<p className="font-medium">Copy value to rows below</p>
|
||||
<p className="text-xs text-muted-foreground">Click to select target rows</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
{isSourceCell && (
|
||||
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 z-20">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => copyDownContext.setIsInCopyDownMode(false)}
|
||||
className="p-1 rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 transition-colors"
|
||||
aria-label="Cancel copy down"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Cancel copy down</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -400,13 +604,18 @@ const ValidationCell = ({
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="truncate overflow-hidden">
|
||||
<div className={`truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}>
|
||||
<BaseCellContent
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasErrors={hasError || isRequiredButEmpty}
|
||||
options={options}
|
||||
className={isSourceCell || isSelectedTarget || isInTargetRow ? `${
|
||||
isSourceCell ? '!bg-blue-100 !border-blue-500 !rounded-md' :
|
||||
isSelectedTarget ? '!bg-blue-200 !border-blue-200 !rounded-md' :
|
||||
isInTargetRow ? 'hover:!bg-blue-100 !border-blue-200 !rounded-md' : ''
|
||||
}` : ''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -72,6 +72,9 @@ const ValidationContainer = <T extends string>({
|
||||
const [isValidatingUpc, setIsValidatingUpc] = useState(false);
|
||||
const [validatingUpcRows, setValidatingUpcRows] = useState<Set<number>>(new Set());
|
||||
|
||||
// Add state for tracking cells in loading state
|
||||
const [validatingCells, setValidatingCells] = useState<Set<string>>(new Set());
|
||||
|
||||
// Store item numbers in a separate state to avoid updating the main data
|
||||
const [itemNumbers, setItemNumbers] = useState<Record<number, string>>({});
|
||||
|
||||
@@ -835,29 +838,79 @@ const ValidationContainer = <T extends string>({
|
||||
}, [enhancedUpdateRow]);
|
||||
|
||||
// Enhanced copy down that uses enhancedUpdateRow instead of regular updateRow
|
||||
const handleCopyDown = useCallback((rowIndex: number, fieldKey: string) => {
|
||||
const handleCopyDown = useCallback((rowIndex: number, fieldKey: string, endRowIndex?: number) => {
|
||||
// Get the value to copy from the source row
|
||||
const sourceRow = data[rowIndex];
|
||||
const valueToCopy = sourceRow[fieldKey];
|
||||
|
||||
// Get all rows below the source row
|
||||
const rowsBelow = data.slice(rowIndex + 1);
|
||||
// Create a proper copy of the value to avoid reference issues, especially for arrays (MultiSelectCell)
|
||||
const valueCopy = Array.isArray(valueToCopy) ? [...valueToCopy] : valueToCopy;
|
||||
|
||||
// Update each row below with the copied value
|
||||
rowsBelow.forEach((_, index) => {
|
||||
// Get all rows below the source row, up to endRowIndex if specified
|
||||
const lastRowIndex = endRowIndex !== undefined ? Math.min(endRowIndex, data.length - 1) : data.length - 1;
|
||||
const rowsToUpdate = data.slice(rowIndex + 1, lastRowIndex + 1);
|
||||
|
||||
// Create a set of cells that will be in loading state
|
||||
const loadingCells = new Set<string>();
|
||||
|
||||
// Add all target cells to the loading state
|
||||
rowsToUpdate.forEach((_, index) => {
|
||||
const targetRowIndex = rowIndex + 1 + index;
|
||||
enhancedUpdateRow(targetRowIndex, fieldKey as T, valueToCopy);
|
||||
loadingCells.add(`${targetRowIndex}-${fieldKey}`);
|
||||
});
|
||||
}, [data, enhancedUpdateRow]);
|
||||
|
||||
// Update validatingCells to show loading state
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
loadingCells.forEach(cell => newSet.add(cell));
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Use setTimeout to allow the UI to update with loading state before processing
|
||||
setTimeout(() => {
|
||||
// Update each row sequentially with a small delay for visual feedback
|
||||
const updateSequentially = async () => {
|
||||
for (let i = 0; i < rowsToUpdate.length; i++) {
|
||||
const targetRowIndex = rowIndex + 1 + i;
|
||||
|
||||
// Update the row with the copied value
|
||||
enhancedUpdateRow(targetRowIndex, fieldKey as T, valueCopy);
|
||||
|
||||
// Remove loading state after a short delay
|
||||
setTimeout(() => {
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(`${targetRowIndex}-${fieldKey}`);
|
||||
return newSet;
|
||||
});
|
||||
}, 100); // Short delay before removing loading state
|
||||
|
||||
// Add a small delay between updates for visual effect
|
||||
if (i < rowsToUpdate.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateSequentially();
|
||||
}, 50);
|
||||
}, [data, enhancedUpdateRow, setValidatingCells]);
|
||||
|
||||
// Memoize the enhanced validation table component
|
||||
const EnhancedValidationTable = useMemo(() => React.memo((props: React.ComponentProps<typeof ValidationTable>) => {
|
||||
// Create validatingCells set from validatingUpcRows, but only for item_number fields
|
||||
// This ensures only the item_number column shows loading state during UPC validation
|
||||
const validatingCells = new Set<string>();
|
||||
const combinedValidatingCells = new Set<string>();
|
||||
|
||||
// Add UPC validation cells
|
||||
validatingUpcRows.forEach(rowIndex => {
|
||||
// Only mark the item_number cells as validating, NOT the UPC or supplier
|
||||
validatingCells.add(`${rowIndex}-item_number`);
|
||||
combinedValidatingCells.add(`${rowIndex}-item_number`);
|
||||
});
|
||||
|
||||
// Add any other validating cells from state
|
||||
validatingCells.forEach(cellKey => {
|
||||
combinedValidatingCells.add(cellKey);
|
||||
});
|
||||
|
||||
// Convert itemNumbers to Map
|
||||
@@ -878,13 +931,13 @@ const ValidationContainer = <T extends string>({
|
||||
<ValidationTable
|
||||
{...props}
|
||||
data={enhancedData}
|
||||
validatingCells={validatingCells}
|
||||
validatingCells={combinedValidatingCells}
|
||||
itemNumbers={itemNumbersMap}
|
||||
isLoadingTemplates={isLoadingTemplates}
|
||||
copyDown={handleCopyDown}
|
||||
/>
|
||||
);
|
||||
}), [validatingUpcRows, itemNumbers, isLoadingTemplates, handleCopyDown]);
|
||||
}), [validatingUpcRows, itemNumbers, isLoadingTemplates, handleCopyDown, validatingCells]);
|
||||
|
||||
// Memoize the rendered validation table
|
||||
const renderValidationTable = useMemo(() => (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useCallback } from 'react'
|
||||
import React, { useMemo, useCallback, useState } from 'react'
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@tanstack/react-table'
|
||||
import { Fields, Field } from '../../../types'
|
||||
import { RowData, Template } from '../hooks/useValidationState'
|
||||
import ValidationCell from './ValidationCell'
|
||||
import ValidationCell, { CopyDownContext } from './ValidationCell'
|
||||
import { useRsi } from '../../../hooks/useRsi'
|
||||
import SearchableTemplateSelect from './SearchableTemplateSelect'
|
||||
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
|
||||
@@ -45,7 +45,7 @@ interface ValidationTableProps<T extends string> {
|
||||
validatingCells: Set<string>
|
||||
itemNumbers: Map<number, string>
|
||||
isLoadingTemplates?: boolean
|
||||
copyDown: (rowIndex: number, key: string) => void
|
||||
copyDown: (rowIndex: number, key: string, endRowIndex?: number) => void
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
@@ -106,7 +106,8 @@ const MemoizedCell = React.memo(({
|
||||
itemNumber,
|
||||
width,
|
||||
rowIndex,
|
||||
copyDown
|
||||
copyDown,
|
||||
totalRows
|
||||
}: {
|
||||
field: Field<string>,
|
||||
value: any,
|
||||
@@ -118,7 +119,8 @@ const MemoizedCell = React.memo(({
|
||||
itemNumber?: string,
|
||||
width: number,
|
||||
rowIndex: number,
|
||||
copyDown?: () => void
|
||||
copyDown?: (endRowIndex?: number) => void,
|
||||
totalRows: number
|
||||
}) => {
|
||||
return (
|
||||
<ValidationCell
|
||||
@@ -133,6 +135,7 @@ const MemoizedCell = React.memo(({
|
||||
width={width}
|
||||
rowIndex={rowIndex}
|
||||
copyDown={copyDown}
|
||||
totalRows={totalRows}
|
||||
/>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
@@ -167,6 +170,50 @@ const ValidationTable = <T extends string>({
|
||||
}: ValidationTableProps<T>) => {
|
||||
const { translations } = useRsi<T>();
|
||||
|
||||
// Add state for copy down selection mode
|
||||
const [isInCopyDownMode, setIsInCopyDownMode] = useState(false);
|
||||
const [sourceRowIndex, setSourceRowIndex] = useState<number | null>(null);
|
||||
const [sourceFieldKey, setSourceFieldKey] = useState<string | null>(null);
|
||||
const [targetRowIndex, setTargetRowIndex] = useState<number | null>(null);
|
||||
|
||||
// Handle copy down completion
|
||||
const handleCopyDownComplete = useCallback((sourceRowIndex: number, fieldKey: string, targetRowIndex: number) => {
|
||||
// Call the copyDown function with the source row index, field key, and target row index
|
||||
copyDown(sourceRowIndex, fieldKey, targetRowIndex);
|
||||
|
||||
// Reset the copy down selection mode
|
||||
setIsInCopyDownMode(false);
|
||||
setSourceRowIndex(null);
|
||||
setSourceFieldKey(null);
|
||||
setTargetRowIndex(null);
|
||||
}, [copyDown]);
|
||||
|
||||
// Create copy down context value
|
||||
const copyDownContextValue = useMemo(() => ({
|
||||
isInCopyDownMode,
|
||||
sourceRowIndex,
|
||||
sourceFieldKey,
|
||||
targetRowIndex,
|
||||
setIsInCopyDownMode,
|
||||
setSourceRowIndex,
|
||||
setSourceFieldKey,
|
||||
setTargetRowIndex,
|
||||
handleCopyDownComplete
|
||||
}), [
|
||||
isInCopyDownMode,
|
||||
sourceRowIndex,
|
||||
sourceFieldKey,
|
||||
targetRowIndex,
|
||||
handleCopyDownComplete
|
||||
]);
|
||||
|
||||
// Update targetRowIndex when hovering over rows in copy down mode
|
||||
const handleRowMouseEnter = useCallback((rowIndex: number) => {
|
||||
if (isInCopyDownMode && sourceRowIndex !== null && rowIndex > sourceRowIndex) {
|
||||
setTargetRowIndex(rowIndex);
|
||||
}
|
||||
}, [isInCopyDownMode, sourceRowIndex]);
|
||||
|
||||
// Memoize the selection column with stable callback
|
||||
const handleSelectAll = useCallback((value: boolean, table: any) => {
|
||||
table.toggleAllPageRowsSelected(!!value);
|
||||
@@ -255,8 +302,8 @@ const ValidationTable = <T extends string>({
|
||||
}, [updateRow]);
|
||||
|
||||
// Memoize the copyDown handler
|
||||
const handleCopyDown = useCallback((rowIndex: number, fieldKey: string) => {
|
||||
copyDown(rowIndex, fieldKey);
|
||||
const handleCopyDown = useCallback((rowIndex: number, fieldKey: string, endRowIndex?: number) => {
|
||||
copyDown(rowIndex, fieldKey, endRowIndex);
|
||||
}, [copyDown]);
|
||||
|
||||
// Memoize field columns with stable handlers
|
||||
@@ -292,12 +339,13 @@ const ValidationTable = <T extends string>({
|
||||
itemNumber={itemNumbers.get(row.index)}
|
||||
width={fieldWidth}
|
||||
rowIndex={row.index}
|
||||
copyDown={() => handleCopyDown(row.index, field.key as string)}
|
||||
copyDown={(endRowIndex?: number) => handleCopyDown(row.index, field.key as string, endRowIndex)}
|
||||
totalRows={data.length}
|
||||
/>
|
||||
)
|
||||
};
|
||||
}).filter((col): col is ColumnDef<RowData<T>, any> => col !== null),
|
||||
[fields, validationErrors, validatingCells, itemNumbers, handleFieldUpdate, handleCopyDown, optionsCache]);
|
||||
[fields, validationErrors, validatingCells, itemNumbers, handleFieldUpdate, handleCopyDown, optionsCache, data.length]);
|
||||
|
||||
// Combine columns
|
||||
const columns = useMemo(() => [selectionColumn, templateColumn, ...fieldColumns], [selectionColumn, templateColumn, fieldColumns]);
|
||||
@@ -335,11 +383,56 @@ const ValidationTable = <T extends string>({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-max">
|
||||
<CopyDownContext.Provider value={copyDownContextValue}>
|
||||
<div className="min-w-max relative">
|
||||
{isInCopyDownMode && sourceRowIndex !== null && sourceFieldKey !== null && (
|
||||
<div className="sticky top-0 z-30 h-0 overflow-visible">
|
||||
<div
|
||||
className="absolute w-[240px] top-16 bg-blue-50 border rounded-2xl shadow-lg border-blue-200 p-3 text-sm text-blue-700 flex items-center justify-between"
|
||||
style={{
|
||||
left: (() => {
|
||||
// Find the column index
|
||||
const colIndex = columns.findIndex(col =>
|
||||
'accessorKey' in col && col.accessorKey === sourceFieldKey
|
||||
);
|
||||
|
||||
// If column not found, position at a default location
|
||||
if (colIndex === -1) return '50px';
|
||||
|
||||
// Calculate position based on column widths
|
||||
let position = 0;
|
||||
for (let i = 0; i < colIndex; i++) {
|
||||
position += columns[i].size || 0;
|
||||
}
|
||||
|
||||
// Add half of the current column width to center it
|
||||
position += (columns[colIndex].size || 0) / 2;
|
||||
|
||||
// Adjust to center the notification
|
||||
position -= 120; // Half of the notification width
|
||||
|
||||
return `${Math.max(50, position)}px`;
|
||||
})()
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">Click on the last row you want to copy to</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsInCopyDownMode(false)}
|
||||
className="text-xs h-7 border-blue-200 text-blue-700 hover:bg-blue-100"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
{/* Custom Table Header - Always Visible */}
|
||||
<div
|
||||
className="sticky top-0 z-20 bg-muted border-b shadow-sm"
|
||||
className={`sticky top-0 z-20 bg-muted border-b shadow-sm`}
|
||||
style={{ width: `${totalWidth}px` }}
|
||||
>
|
||||
<div className="flex">
|
||||
@@ -376,6 +469,7 @@ const ValidationTable = <T extends string>({
|
||||
validationErrors.get(data.indexOf(row.original)) &&
|
||||
Object.keys(validationErrors.get(data.indexOf(row.original)) || {}).length > 0 ? "bg-red-50/40" : ""
|
||||
)}
|
||||
onMouseEnter={() => handleRowMouseEnter(row.index)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell, cellIndex) => {
|
||||
const width = cell.column.getSize();
|
||||
@@ -400,6 +494,7 @@ const ValidationTable = <T extends string>({
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CopyDownContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { Field } from '../../../../types'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { cn } from '@/lib/utils'
|
||||
import React from 'react'
|
||||
|
||||
interface CheckboxCellProps<T extends string> {
|
||||
field: Field<T>
|
||||
@@ -9,6 +10,7 @@ interface CheckboxCellProps<T extends string> {
|
||||
onChange: (value: any) => void
|
||||
hasErrors?: boolean
|
||||
booleanMatches?: Record<string, boolean>
|
||||
className?: string
|
||||
}
|
||||
|
||||
const CheckboxCell = <T extends string>({
|
||||
@@ -16,9 +18,12 @@ const CheckboxCell = <T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
hasErrors,
|
||||
booleanMatches = {}
|
||||
booleanMatches = {},
|
||||
className = ''
|
||||
}: CheckboxCellProps<T>) => {
|
||||
const [checked, setChecked] = useState(false)
|
||||
// Add state for hover
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
// Initialize checkbox state
|
||||
useEffect(() => {
|
||||
@@ -70,6 +75,12 @@ const CheckboxCell = <T extends string>({
|
||||
setChecked(!!value)
|
||||
}, [value, field.fieldType, booleanMatches])
|
||||
|
||||
// Helper function to check if a class is present in the className string
|
||||
const hasClass = (cls: string): boolean => {
|
||||
const classNames = (className || '').split(' ');
|
||||
return classNames.includes(cls);
|
||||
};
|
||||
|
||||
// Handle checkbox change
|
||||
const handleChange = useCallback((checked: boolean) => {
|
||||
setChecked(checked)
|
||||
@@ -80,11 +91,27 @@ const CheckboxCell = <T extends string>({
|
||||
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center h-10 px-2 py-1 rounded-md",
|
||||
outlineClass,
|
||||
hasErrors ? "bg-red-50 border-destructive" : "border-input"
|
||||
)}>
|
||||
hasErrors ? "bg-red-50 border-destructive" : "border-input",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={handleChange}
|
||||
@@ -96,4 +123,23 @@ const CheckboxCell = <T extends string>({
|
||||
)
|
||||
}
|
||||
|
||||
export default CheckboxCell
|
||||
export default React.memo(CheckboxCell, (prev, next) => {
|
||||
if (prev.hasErrors !== next.hasErrors) return false;
|
||||
if (prev.field !== next.field) return false;
|
||||
if (prev.value !== next.value) return false;
|
||||
if (prev.className !== next.className) return false;
|
||||
|
||||
// Compare booleanMatches objects
|
||||
const prevMatches = prev.booleanMatches || {};
|
||||
const nextMatches = next.booleanMatches || {};
|
||||
const prevKeys = Object.keys(prevMatches);
|
||||
const nextKeys = Object.keys(nextMatches);
|
||||
|
||||
if (prevKeys.length !== nextKeys.length) return false;
|
||||
|
||||
for (const key of prevKeys) {
|
||||
if (prevMatches[key] !== nextMatches[key]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
@@ -14,6 +14,7 @@ interface InputCellProps<T extends string> {
|
||||
isMultiline?: boolean
|
||||
isPrice?: boolean
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Add efficient price formatting utility
|
||||
@@ -39,7 +40,8 @@ const InputCell = <T extends string>({
|
||||
hasErrors,
|
||||
isMultiline = false,
|
||||
isPrice = false,
|
||||
disabled = false
|
||||
disabled = false,
|
||||
className = ''
|
||||
}: InputCellProps<T>) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
@@ -52,6 +54,15 @@ const InputCell = <T extends string>({
|
||||
// Track local display value to avoid waiting for validation
|
||||
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
|
||||
|
||||
// Add state for hover
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// Helper function to check if a class is present in the className string
|
||||
const hasClass = (cls: string): boolean => {
|
||||
const classNames = className.split(' ');
|
||||
return classNames.includes(cls);
|
||||
};
|
||||
|
||||
// Initialize localDisplayValue on mount and when value changes externally
|
||||
useEffect(() => {
|
||||
if (localDisplayValue === null ||
|
||||
@@ -151,11 +162,26 @@ const InputCell = <T extends string>({
|
||||
// If disabled, just render the value without any interactivity
|
||||
if (disabled) {
|
||||
return (
|
||||
<div className={cn(
|
||||
<div
|
||||
className={cn(
|
||||
"px-3 py-2 h-10 rounded-md text-sm w-full",
|
||||
outlineClass,
|
||||
hasErrors ? "border-destructive" : "border-input"
|
||||
)}>
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
);
|
||||
@@ -170,6 +196,7 @@ const InputCell = <T extends string>({
|
||||
onChange={onChange}
|
||||
hasErrors={hasErrors}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -188,8 +215,18 @@ const InputCell = <T extends string>({
|
||||
className={cn(
|
||||
outlineClass,
|
||||
hasErrors ? "border-destructive" : "",
|
||||
isPending ? "opacity-50" : ""
|
||||
isPending ? "opacity-50" : "",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
@@ -199,6 +236,19 @@ const InputCell = <T extends string>({
|
||||
outlineClass,
|
||||
hasErrors ? "border-destructive" : "border-input"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ interface MultiSelectCellProps<T extends string> {
|
||||
hasErrors?: boolean
|
||||
options?: readonly FieldOption[]
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Memoized option item to prevent unnecessary renders for large option lists
|
||||
@@ -155,14 +156,17 @@ const MultiSelectCell = <T extends string>({
|
||||
onEndEdit,
|
||||
hasErrors,
|
||||
options: providedOptions,
|
||||
disabled = false
|
||||
disabled = false,
|
||||
className = ''
|
||||
}: MultiSelectCellProps<T>) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
// Add internal state for tracking selections
|
||||
const [internalValue, setInternalValue] = useState<string[]>(value)
|
||||
// Add internal state for tracking selections - ensure value is always an array
|
||||
const [internalValue, setInternalValue] = useState<string[]>(Array.isArray(value) ? value : [])
|
||||
// Ref for the command list to enable scrolling
|
||||
const commandListRef = useRef<HTMLDivElement>(null)
|
||||
// Add state for hover
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
// Create a memoized Set for fast lookups of selected values
|
||||
const selectedValueSet = useMemo(() => new Set(internalValue), [internalValue]);
|
||||
@@ -170,7 +174,8 @@ const MultiSelectCell = <T extends string>({
|
||||
// Sync internalValue with external value when component mounts or value changes externally
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setInternalValue(value)
|
||||
// Ensure value is always an array
|
||||
setInternalValue(Array.isArray(value) ? value : [])
|
||||
}
|
||||
}, [value, open])
|
||||
|
||||
@@ -306,35 +311,56 @@ const MultiSelectCell = <T extends string>({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// If disabled, render a static view
|
||||
// Helper function to check if a class is present in the className string
|
||||
const hasClass = (cls: string): boolean => {
|
||||
const classNames = className.split(' ');
|
||||
return classNames.includes(cls);
|
||||
};
|
||||
|
||||
// If disabled, just render the value without any interactivity
|
||||
if (disabled) {
|
||||
// Handle array values
|
||||
const displayValue = Array.isArray(value)
|
||||
? value.map(v => {
|
||||
const option = providedOptions?.find(o => o.value === v);
|
||||
return option ? option.label : v;
|
||||
const displayValue = internalValue.length > 0
|
||||
? internalValue.map(val => {
|
||||
const option = selectOptions.find(opt => opt.value === val);
|
||||
return option ? option.label : val;
|
||||
}).join(', ')
|
||||
: value;
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
<div
|
||||
className={cn(
|
||||
"w-full px-3 py-2 h-10 rounded-md text-sm flex items-center",
|
||||
"border",
|
||||
hasErrors ? "border-destructive" : "border-input"
|
||||
)}>
|
||||
hasErrors ? "border-destructive" : "border-input",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{displayValue || ""}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
handleOpenChange(isOpen);
|
||||
}}
|
||||
>
|
||||
<Popover open={open} onOpenChange={(o) => {
|
||||
// Only open the popover if we're not in copy down mode
|
||||
if (!hasClass('!bg-blue-100') && !hasClass('!bg-blue-200') && !hasClass('hover:!bg-blue-100')) {
|
||||
setOpen(o);
|
||||
handleOpenChange(o);
|
||||
}
|
||||
}}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -344,8 +370,35 @@ const MultiSelectCell = <T extends string>({
|
||||
"w-full justify-between font-normal",
|
||||
"border",
|
||||
!internalValue.length && "text-muted-foreground",
|
||||
hasErrors ? "border-destructive" : ""
|
||||
hasErrors ? "border-destructive" : "",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
|
||||
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Don't open the dropdown if we're in copy down mode
|
||||
if (hasClass('!bg-blue-100') || hasClass('!bg-blue-200') || hasClass('hover:!bg-blue-100')) {
|
||||
// Let the parent cell handle the click by NOT preventing default or stopping propagation
|
||||
return;
|
||||
}
|
||||
|
||||
// Only prevent default and stop propagation if not in copy down mode
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(!open);
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="flex items-center w-full justify-between">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
@@ -411,46 +464,37 @@ const MultiSelectCell = <T extends string>({
|
||||
MultiSelectCell.displayName = 'MultiSelectCell';
|
||||
|
||||
export default React.memo(MultiSelectCell, (prev, next) => {
|
||||
// Quick check for reference equality of simple props
|
||||
if (prev.hasErrors !== next.hasErrors ||
|
||||
prev.disabled !== next.disabled) {
|
||||
return false;
|
||||
// Check primitive props first (cheap comparisons)
|
||||
if (prev.hasErrors !== next.hasErrors) return false;
|
||||
if (prev.disabled !== next.disabled) return false;
|
||||
if (prev.className !== next.className) return false;
|
||||
|
||||
// Check field reference
|
||||
if (prev.field !== next.field) return false;
|
||||
|
||||
// Check value arrays (potentially expensive for large arrays)
|
||||
// Handle undefined or null values safely
|
||||
const prevValue = prev.value || [];
|
||||
const nextValue = next.value || [];
|
||||
|
||||
if (prevValue.length !== nextValue.length) return false;
|
||||
for (let i = 0; i < prevValue.length; i++) {
|
||||
if (prevValue[i] !== nextValue[i]) return false;
|
||||
}
|
||||
|
||||
// Array comparison for value
|
||||
if (Array.isArray(prev.value) && Array.isArray(next.value)) {
|
||||
if (prev.value.length !== next.value.length) return false;
|
||||
// Check options (potentially expensive for large option lists)
|
||||
const prevOptions = prev.options || [];
|
||||
const nextOptions = next.options || [];
|
||||
if (prevOptions.length !== nextOptions.length) return false;
|
||||
|
||||
// Check each item in the array - optimize for large arrays
|
||||
if (prev.value.length > 50) {
|
||||
// For large arrays, JSON stringify is actually faster than iterating
|
||||
return JSON.stringify(prev.value) === JSON.stringify(next.value);
|
||||
// For large option lists, just compare references
|
||||
if (prevOptions.length > 100) {
|
||||
return prevOptions === nextOptions;
|
||||
}
|
||||
|
||||
// For smaller arrays, iterative comparison is more efficient
|
||||
for (let i = 0; i < prev.value.length; i++) {
|
||||
if (prev.value[i] !== next.value[i]) return false;
|
||||
}
|
||||
} else if (prev.value !== next.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only do a full options comparison if they are different references and small arrays
|
||||
if (prev.options !== next.options) {
|
||||
if (!prev.options || !next.options) return false;
|
||||
if (prev.options.length !== next.options.length) return false;
|
||||
|
||||
// For large option lists, just check reference equality
|
||||
if (prev.options.length > 100) return false;
|
||||
|
||||
// For smaller lists, check if any values differ
|
||||
for (let i = 0; i < prev.options.length; i++) {
|
||||
const prevOpt = prev.options[i];
|
||||
const nextOpt = next.options[i];
|
||||
if (prevOpt.value !== nextOpt.value || prevOpt.label !== nextOpt.label) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// For smaller lists, do a shallow comparison
|
||||
for (let i = 0; i < prevOptions.length; i++) {
|
||||
if (prevOptions[i] !== nextOptions[i]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -12,6 +12,7 @@ interface MultilineInputProps<T extends string> {
|
||||
onChange: (value: any) => void
|
||||
hasErrors?: boolean
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const MultilineInput = <T extends string>({
|
||||
@@ -19,7 +20,8 @@ const MultilineInput = <T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
hasErrors = false,
|
||||
disabled = false
|
||||
disabled = false,
|
||||
className = ''
|
||||
}: MultilineInputProps<T>) => {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
@@ -28,6 +30,15 @@ const MultilineInput = <T extends string>({
|
||||
const preventReopenRef = useRef(false);
|
||||
const pendingChangeRef = useRef<string | null>(null);
|
||||
|
||||
// Add state for hover
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// Helper function to check if a class is present in the className string
|
||||
const hasClass = (cls: string): boolean => {
|
||||
const classNames = (className || '').split(' ');
|
||||
return classNames.includes(cls);
|
||||
};
|
||||
|
||||
// Initialize localDisplayValue on mount and when value changes externally
|
||||
useEffect(() => {
|
||||
if (localDisplayValue === null ||
|
||||
@@ -123,11 +134,26 @@ const MultilineInput = <T extends string>({
|
||||
// If disabled, just render the value without any interactivity
|
||||
if (disabled) {
|
||||
return (
|
||||
<div className={cn(
|
||||
<div
|
||||
className={cn(
|
||||
"px-3 py-2 min-h-[80px] max-h-[80px] rounded-md text-sm w-full",
|
||||
outlineClass,
|
||||
hasErrors ? "border-destructive" : "border-input"
|
||||
)}>
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
);
|
||||
@@ -143,8 +169,22 @@ const MultilineInput = <T extends string>({
|
||||
"px-3 py-2 min-h-[80px] max-h-[80px] rounded-md text-sm w-full cursor-pointer",
|
||||
"overflow-hidden whitespace-pre-wrap",
|
||||
outlineClass,
|
||||
hasErrors ? "border-destructive" : "border-input"
|
||||
hasErrors ? "border-destructive" : "border-input",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
@@ -189,5 +229,6 @@ export default React.memo(MultilineInput, (prev, next) => {
|
||||
if (prev.disabled !== next.disabled) return false;
|
||||
if (prev.field !== next.field) return false;
|
||||
if (prev.value !== next.value) return false;
|
||||
if (prev.className !== next.className) return false;
|
||||
return true;
|
||||
});
|
||||
@@ -21,6 +21,7 @@ interface SelectCellProps<T extends string> {
|
||||
hasErrors?: boolean
|
||||
options: readonly any[]
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Lightweight version of the select cell with minimal dependencies
|
||||
@@ -32,7 +33,8 @@ const SelectCell = <T extends string>({
|
||||
onEndEdit,
|
||||
hasErrors,
|
||||
options = [],
|
||||
disabled = false
|
||||
disabled = false,
|
||||
className = ''
|
||||
}: SelectCellProps<T>) => {
|
||||
// State for the open/closed state of the dropdown
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -46,6 +48,15 @@ const SelectCell = <T extends string>({
|
||||
// State to track if the value is being processed/validated
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
// Add state for hover
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// Helper function to check if a class is present in the className string
|
||||
const hasClass = (cls: string): boolean => {
|
||||
const classNames = className.split(' ');
|
||||
return classNames.includes(cls);
|
||||
};
|
||||
|
||||
// Update internal value when prop value changes
|
||||
useEffect(() => {
|
||||
setInternalValue(value);
|
||||
@@ -140,8 +151,23 @@ const SelectCell = <T extends string>({
|
||||
"w-full px-3 py-2 h-10 rounded-md text-sm flex items-center",
|
||||
"border",
|
||||
hasErrors ? "border-destructive" : "border-input",
|
||||
isProcessing ? "text-muted-foreground" : ""
|
||||
)}>
|
||||
isProcessing ? "text-muted-foreground" : "",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{displayText || ""}
|
||||
</div>
|
||||
);
|
||||
@@ -151,8 +177,11 @@ const SelectCell = <T extends string>({
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
// Only open the popover if we're not in copy down mode
|
||||
if (!hasClass('!bg-blue-100') && !hasClass('!bg-blue-200') && !hasClass('hover:!bg-blue-100')) {
|
||||
setOpen(isOpen);
|
||||
if (isOpen && onStartEdit) onStartEdit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -165,14 +194,36 @@ const SelectCell = <T extends string>({
|
||||
"border",
|
||||
!internalValue && "text-muted-foreground",
|
||||
isProcessing && "text-muted-foreground",
|
||||
hasErrors ? "border-destructive" : ""
|
||||
hasErrors ? "border-destructive" : "",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
|
||||
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Don't open the dropdown if we're in copy down mode
|
||||
if (hasClass('!bg-blue-100') || hasClass('!bg-blue-200') || hasClass('hover:!bg-blue-100')) {
|
||||
// Let the parent cell handle the click
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(!open);
|
||||
if (!open && onStartEdit) onStartEdit();
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<span className={isProcessing ? "opacity-70" : ""}>
|
||||
{displayValue}
|
||||
@@ -224,6 +275,7 @@ export default React.memo(SelectCell, (prev, next) => {
|
||||
if (prev.value !== next.value) return false;
|
||||
if (prev.hasErrors !== next.hasErrors) return false;
|
||||
if (prev.disabled !== next.disabled) return false;
|
||||
if (prev.className !== next.className) return false;
|
||||
|
||||
// Only check options array for reference equality - we're handling deep comparison internally
|
||||
if (prev.options !== next.options &&
|
||||
|
||||
Reference in New Issue
Block a user