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 React from 'react'
|
||||||
import { Field } from '../../../types'
|
import { Field } from '../../../types'
|
||||||
import { Loader2, AlertCircle, ArrowDown } from 'lucide-react'
|
import { Loader2, AlertCircle, ArrowDown, Check, X } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -12,6 +12,29 @@ import SelectCell from './cells/SelectCell'
|
|||||||
import MultiSelectCell from './cells/MultiSelectCell'
|
import MultiSelectCell from './cells/MultiSelectCell'
|
||||||
import { TableCell } from '@/components/ui/table'
|
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
|
// Define error object type
|
||||||
type ErrorObject = {
|
type ErrorObject = {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -51,13 +74,15 @@ const BaseCellContent = React.memo(({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
hasErrors,
|
hasErrors,
|
||||||
options = []
|
options = [],
|
||||||
|
className = ''
|
||||||
}: {
|
}: {
|
||||||
field: Field<string>;
|
field: Field<string>;
|
||||||
value: any;
|
value: any;
|
||||||
onChange: (value: any) => void;
|
onChange: (value: any) => void;
|
||||||
hasErrors: boolean;
|
hasErrors: boolean;
|
||||||
options?: readonly any[];
|
options?: readonly any[];
|
||||||
|
className?: string;
|
||||||
}) => {
|
}) => {
|
||||||
// Get field type information
|
// Get field type information
|
||||||
const fieldType = typeof field.fieldType === 'string'
|
const fieldType = typeof field.fieldType === 'string'
|
||||||
@@ -82,6 +107,7 @@ const BaseCellContent = React.memo(({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
options={options}
|
options={options}
|
||||||
hasErrors={hasErrors}
|
hasErrors={hasErrors}
|
||||||
|
className={className}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -94,6 +120,7 @@ const BaseCellContent = React.memo(({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
options={options}
|
options={options}
|
||||||
hasErrors={hasErrors}
|
hasErrors={hasErrors}
|
||||||
|
className={className}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -119,6 +146,7 @@ const BaseCellContent = React.memo(({
|
|||||||
prev.value === next.value &&
|
prev.value === next.value &&
|
||||||
prev.hasErrors === next.hasErrors &&
|
prev.hasErrors === next.hasErrors &&
|
||||||
prev.field === next.field &&
|
prev.field === next.field &&
|
||||||
|
prev.className === next.className &&
|
||||||
optionsEqual
|
optionsEqual
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -136,7 +164,8 @@ export interface ValidationCellProps {
|
|||||||
itemNumber?: string
|
itemNumber?: string
|
||||||
width: number
|
width: number
|
||||||
rowIndex: number
|
rowIndex: number
|
||||||
copyDown?: () => void
|
copyDown?: (endRowIndex?: number) => void
|
||||||
|
totalRows?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add efficient error message extraction function
|
// Add efficient error message extraction function
|
||||||
@@ -223,7 +252,9 @@ const ItemNumberCell = React.memo(({
|
|||||||
errors = [],
|
errors = [],
|
||||||
field,
|
field,
|
||||||
onChange,
|
onChange,
|
||||||
copyDown
|
copyDown,
|
||||||
|
rowIndex,
|
||||||
|
totalRows = 0
|
||||||
}: {
|
}: {
|
||||||
value: any,
|
value: any,
|
||||||
itemNumber?: string,
|
itemNumber?: string,
|
||||||
@@ -232,7 +263,9 @@ const ItemNumberCell = React.memo(({
|
|||||||
errors?: ErrorObject[],
|
errors?: ErrorObject[],
|
||||||
field: Field<string>,
|
field: Field<string>,
|
||||||
onChange: (value: any) => void,
|
onChange: (value: any) => void,
|
||||||
copyDown?: () => void
|
copyDown?: (endRowIndex?: number) => void,
|
||||||
|
rowIndex: number,
|
||||||
|
totalRows?: number
|
||||||
}) => {
|
}) => {
|
||||||
// If we have a value or itemNumber, ignore "required" errors
|
// If we have a value or itemNumber, ignore "required" errors
|
||||||
const displayValue = itemNumber || value;
|
const displayValue = itemNumber || value;
|
||||||
@@ -248,10 +281,67 @@ const ItemNumberCell = React.memo(({
|
|||||||
[displayValue, errors]
|
[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 (
|
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' : ''}`}>
|
<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">
|
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 z-20">
|
||||||
<ValidationIcon error={{
|
<ValidationIcon error={{
|
||||||
message: errorMessages,
|
message: errorMessages,
|
||||||
@@ -259,38 +349,69 @@ const ItemNumberCell = React.memo(({
|
|||||||
}} />
|
}} />
|
||||||
</div>
|
</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">
|
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 z-20 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
onClick={copyDown}
|
onClick={handleCopyDownClick}
|
||||||
className="p-1 rounded-sm hover:bg-muted text-muted-foreground/50 hover:text-foreground"
|
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" />
|
<ArrowDown className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent side="right">
|
||||||
<p>Copy value to all cells below</p>
|
<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>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isValidating ? (
|
{isValidating ? (
|
||||||
<div className={`flex items-center justify-center gap-2 border ${hasError || isRequiredButEmpty ? 'border-red-500' : 'border-input'} rounded-md h-10`}>
|
<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-foreground ml-2" />
|
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
||||||
<span>{displayValue || ''}</span>
|
<span>Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="truncate overflow-hidden">
|
<div className={`truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}>
|
||||||
<BaseCellContent
|
<BaseCellContent
|
||||||
field={field}
|
field={field}
|
||||||
value={displayValue}
|
value={displayValue}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
hasErrors={hasError || isRequiredButEmpty}
|
hasErrors={hasError || isRequiredButEmpty}
|
||||||
options={(field.fieldType && typeof field.fieldType === 'object' && (field.fieldType as any).options) || []}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -316,7 +437,17 @@ const ValidationCell = ({
|
|||||||
options = [],
|
options = [],
|
||||||
itemNumber,
|
itemNumber,
|
||||||
width,
|
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
|
// For item_number fields, use the specialized component
|
||||||
if (fieldKey === 'item_number') {
|
if (fieldKey === 'item_number') {
|
||||||
return (
|
return (
|
||||||
@@ -329,6 +460,8 @@ const ValidationCell = ({
|
|||||||
field={field}
|
field={field}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
copyDown={copyDown}
|
copyDown={copyDown}
|
||||||
|
rowIndex={rowIndex}
|
||||||
|
totalRows={totalRows}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -360,14 +493,59 @@ const ValidationCell = ({
|
|||||||
return { hasError, isRequiredButEmpty, shouldShowErrorIcon, errorMessages };
|
return { hasError, isRequiredButEmpty, shouldShowErrorIcon, errorMessages };
|
||||||
}, [filteredErrors, value, errors]);
|
}, [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 (
|
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' : ''}`}>
|
<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">
|
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 z-20">
|
||||||
<ValidationIcon error={{
|
<ValidationIcon error={{
|
||||||
message: errorMessages,
|
message: errorMessages,
|
||||||
@@ -375,20 +553,46 @@ const ValidationCell = ({
|
|||||||
}} />
|
}} />
|
||||||
</div>
|
</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">
|
<div className="absolute right-0.5 top-1/2 -translate-y-1/2 z-20 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
onClick={copyDown}
|
onClick={handleCopyDownClick}
|
||||||
className="p-1 rounded-full hover:bg-muted text-muted-foreground/50 hover:text-foreground"
|
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" />
|
<ArrowDown className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent side="right">
|
||||||
<p>Copy value to all cells below</p>
|
<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>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -400,13 +604,18 @@ const ValidationCell = ({
|
|||||||
<span>Loading...</span>
|
<span>Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="truncate overflow-hidden">
|
<div className={`truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}>
|
||||||
<BaseCellContent
|
<BaseCellContent
|
||||||
field={field}
|
field={field}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
hasErrors={hasError || isRequiredButEmpty}
|
hasErrors={hasError || isRequiredButEmpty}
|
||||||
options={options}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ const ValidationContainer = <T extends string>({
|
|||||||
const [isValidatingUpc, setIsValidatingUpc] = useState(false);
|
const [isValidatingUpc, setIsValidatingUpc] = useState(false);
|
||||||
const [validatingUpcRows, setValidatingUpcRows] = useState<Set<number>>(new Set());
|
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
|
// Store item numbers in a separate state to avoid updating the main data
|
||||||
const [itemNumbers, setItemNumbers] = useState<Record<number, string>>({});
|
const [itemNumbers, setItemNumbers] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
@@ -835,29 +838,79 @@ const ValidationContainer = <T extends string>({
|
|||||||
}, [enhancedUpdateRow]);
|
}, [enhancedUpdateRow]);
|
||||||
|
|
||||||
// Enhanced copy down that uses enhancedUpdateRow instead of regular updateRow
|
// 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
|
// Get the value to copy from the source row
|
||||||
const sourceRow = data[rowIndex];
|
const sourceRow = data[rowIndex];
|
||||||
const valueToCopy = sourceRow[fieldKey];
|
const valueToCopy = sourceRow[fieldKey];
|
||||||
|
|
||||||
|
// Create a proper copy of the value to avoid reference issues, especially for arrays (MultiSelectCell)
|
||||||
|
const valueCopy = Array.isArray(valueToCopy) ? [...valueToCopy] : valueToCopy;
|
||||||
|
|
||||||
// Get all rows below the source row
|
// Get all rows below the source row, up to endRowIndex if specified
|
||||||
const rowsBelow = data.slice(rowIndex + 1);
|
const lastRowIndex = endRowIndex !== undefined ? Math.min(endRowIndex, data.length - 1) : data.length - 1;
|
||||||
|
const rowsToUpdate = data.slice(rowIndex + 1, lastRowIndex + 1);
|
||||||
// Update each row below with the copied value
|
|
||||||
rowsBelow.forEach((_, index) => {
|
// 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;
|
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
|
// Memoize the enhanced validation table component
|
||||||
const EnhancedValidationTable = useMemo(() => React.memo((props: React.ComponentProps<typeof ValidationTable>) => {
|
const EnhancedValidationTable = useMemo(() => React.memo((props: React.ComponentProps<typeof ValidationTable>) => {
|
||||||
// Create validatingCells set from validatingUpcRows, but only for item_number fields
|
// Create validatingCells set from validatingUpcRows, but only for item_number fields
|
||||||
// This ensures only the item_number column shows loading state during UPC validation
|
// 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 => {
|
validatingUpcRows.forEach(rowIndex => {
|
||||||
// Only mark the item_number cells as validating, NOT the UPC or supplier
|
// 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
|
// Convert itemNumbers to Map
|
||||||
@@ -878,13 +931,13 @@ const ValidationContainer = <T extends string>({
|
|||||||
<ValidationTable
|
<ValidationTable
|
||||||
{...props}
|
{...props}
|
||||||
data={enhancedData}
|
data={enhancedData}
|
||||||
validatingCells={validatingCells}
|
validatingCells={combinedValidatingCells}
|
||||||
itemNumbers={itemNumbersMap}
|
itemNumbers={itemNumbersMap}
|
||||||
isLoadingTemplates={isLoadingTemplates}
|
isLoadingTemplates={isLoadingTemplates}
|
||||||
copyDown={handleCopyDown}
|
copyDown={handleCopyDown}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}), [validatingUpcRows, itemNumbers, isLoadingTemplates, handleCopyDown]);
|
}), [validatingUpcRows, itemNumbers, isLoadingTemplates, handleCopyDown, validatingCells]);
|
||||||
|
|
||||||
// Memoize the rendered validation table
|
// Memoize the rendered validation table
|
||||||
const renderValidationTable = useMemo(() => (
|
const renderValidationTable = useMemo(() => (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useCallback } from 'react'
|
import React, { useMemo, useCallback, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
useReactTable,
|
useReactTable,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from '@tanstack/react-table'
|
} 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 ValidationCell from './ValidationCell'
|
import ValidationCell, { CopyDownContext } 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'
|
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
|
||||||
@@ -45,7 +45,7 @@ interface ValidationTableProps<T extends string> {
|
|||||||
validatingCells: Set<string>
|
validatingCells: Set<string>
|
||||||
itemNumbers: Map<number, string>
|
itemNumbers: Map<number, string>
|
||||||
isLoadingTemplates?: boolean
|
isLoadingTemplates?: boolean
|
||||||
copyDown: (rowIndex: number, key: string) => void
|
copyDown: (rowIndex: number, key: string, endRowIndex?: number) => void
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +106,8 @@ const MemoizedCell = React.memo(({
|
|||||||
itemNumber,
|
itemNumber,
|
||||||
width,
|
width,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
copyDown
|
copyDown,
|
||||||
|
totalRows
|
||||||
}: {
|
}: {
|
||||||
field: Field<string>,
|
field: Field<string>,
|
||||||
value: any,
|
value: any,
|
||||||
@@ -118,7 +119,8 @@ const MemoizedCell = React.memo(({
|
|||||||
itemNumber?: string,
|
itemNumber?: string,
|
||||||
width: number,
|
width: number,
|
||||||
rowIndex: number,
|
rowIndex: number,
|
||||||
copyDown?: () => void
|
copyDown?: (endRowIndex?: number) => void,
|
||||||
|
totalRows: number
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<ValidationCell
|
<ValidationCell
|
||||||
@@ -133,6 +135,7 @@ const MemoizedCell = React.memo(({
|
|||||||
width={width}
|
width={width}
|
||||||
rowIndex={rowIndex}
|
rowIndex={rowIndex}
|
||||||
copyDown={copyDown}
|
copyDown={copyDown}
|
||||||
|
totalRows={totalRows}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, (prev, next) => {
|
}, (prev, next) => {
|
||||||
@@ -167,6 +170,50 @@ const ValidationTable = <T extends string>({
|
|||||||
}: ValidationTableProps<T>) => {
|
}: ValidationTableProps<T>) => {
|
||||||
const { translations } = useRsi<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
|
// Memoize the selection column with stable callback
|
||||||
const handleSelectAll = useCallback((value: boolean, table: any) => {
|
const handleSelectAll = useCallback((value: boolean, table: any) => {
|
||||||
table.toggleAllPageRowsSelected(!!value);
|
table.toggleAllPageRowsSelected(!!value);
|
||||||
@@ -255,8 +302,8 @@ const ValidationTable = <T extends string>({
|
|||||||
}, [updateRow]);
|
}, [updateRow]);
|
||||||
|
|
||||||
// Memoize the copyDown handler
|
// Memoize the copyDown handler
|
||||||
const handleCopyDown = useCallback((rowIndex: number, fieldKey: string) => {
|
const handleCopyDown = useCallback((rowIndex: number, fieldKey: string, endRowIndex?: number) => {
|
||||||
copyDown(rowIndex, fieldKey);
|
copyDown(rowIndex, fieldKey, endRowIndex);
|
||||||
}, [copyDown]);
|
}, [copyDown]);
|
||||||
|
|
||||||
// Memoize field columns with stable handlers
|
// Memoize field columns with stable handlers
|
||||||
@@ -292,12 +339,13 @@ const ValidationTable = <T extends string>({
|
|||||||
itemNumber={itemNumbers.get(row.index)}
|
itemNumber={itemNumbers.get(row.index)}
|
||||||
width={fieldWidth}
|
width={fieldWidth}
|
||||||
rowIndex={row.index}
|
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),
|
}).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
|
// Combine columns
|
||||||
const columns = useMemo(() => [selectionColumn, templateColumn, ...fieldColumns], [selectionColumn, templateColumn, fieldColumns]);
|
const columns = useMemo(() => [selectionColumn, templateColumn, ...fieldColumns], [selectionColumn, templateColumn, fieldColumns]);
|
||||||
@@ -335,71 +383,118 @@ const ValidationTable = <T extends string>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-max">
|
<CopyDownContext.Provider value={copyDownContextValue}>
|
||||||
<div className="relative">
|
<div className="min-w-max relative">
|
||||||
{/* Custom Table Header - Always Visible */}
|
{isInCopyDownMode && sourceRowIndex !== null && sourceFieldKey !== null && (
|
||||||
<div
|
<div className="sticky top-0 z-30 h-0 overflow-visible">
|
||||||
className="sticky top-0 z-20 bg-muted border-b shadow-sm"
|
<div
|
||||||
style={{ width: `${totalWidth}px` }}
|
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={{
|
||||||
<div className="flex">
|
left: (() => {
|
||||||
{table.getFlatHeaders().map((header, index) => {
|
// Find the column index
|
||||||
const width = header.getSize();
|
const colIndex = columns.findIndex(col =>
|
||||||
return (
|
'accessorKey' in col && col.accessorKey === sourceFieldKey
|
||||||
<div
|
|
||||||
key={header.id}
|
|
||||||
className="py-2 px-2 font-bold text-sm text-muted-foreground bg-muted flex items-center justify-center"
|
|
||||||
style={{
|
|
||||||
width: `${width}px`,
|
|
||||||
minWidth: `${width}px`,
|
|
||||||
maxWidth: `${width}px`,
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
height: '40px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table Body */}
|
|
||||||
<Table style={{ width: `${totalWidth}px`, tableLayout: 'fixed', borderCollapse: 'separate', borderSpacing: 0, marginTop: '-1px' }}>
|
|
||||||
<TableBody>
|
|
||||||
{table.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
className={cn(
|
|
||||||
"hover:bg-muted/50",
|
|
||||||
row.getIsSelected() ? "bg-muted/50" : "",
|
|
||||||
validationErrors.get(data.indexOf(row.original)) &&
|
|
||||||
Object.keys(validationErrors.get(data.indexOf(row.original)) || {}).length > 0 ? "bg-red-50/40" : ""
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell, cellIndex) => {
|
|
||||||
const width = cell.column.getSize();
|
|
||||||
return (
|
|
||||||
<TableCell
|
|
||||||
key={cell.id}
|
|
||||||
style={{
|
|
||||||
width: `${width}px`,
|
|
||||||
minWidth: `${width}px`,
|
|
||||||
maxWidth: `${width}px`,
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
padding: '0'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</TableCell>
|
|
||||||
);
|
);
|
||||||
})}
|
|
||||||
</TableRow>
|
// If column not found, position at a default location
|
||||||
))}
|
if (colIndex === -1) return '50px';
|
||||||
</TableBody>
|
|
||||||
</Table>
|
// 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`}
|
||||||
|
style={{ width: `${totalWidth}px` }}
|
||||||
|
>
|
||||||
|
<div className="flex">
|
||||||
|
{table.getFlatHeaders().map((header, index) => {
|
||||||
|
const width = header.getSize();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={header.id}
|
||||||
|
className="py-2 px-2 font-bold text-sm text-muted-foreground bg-muted flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
width: `${width}px`,
|
||||||
|
minWidth: `${width}px`,
|
||||||
|
maxWidth: `${width}px`,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
height: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Body */}
|
||||||
|
<Table style={{ width: `${totalWidth}px`, tableLayout: 'fixed', borderCollapse: 'separate', borderSpacing: 0, marginTop: '-1px' }}>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/50",
|
||||||
|
row.getIsSelected() ? "bg-muted/50" : "",
|
||||||
|
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();
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
style={{
|
||||||
|
width: `${width}px`,
|
||||||
|
minWidth: `${width}px`,
|
||||||
|
maxWidth: `${width}px`,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
padding: '0'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CopyDownContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import { Field } from '../../../../types'
|
import { Field } from '../../../../types'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
interface CheckboxCellProps<T extends string> {
|
interface CheckboxCellProps<T extends string> {
|
||||||
field: Field<T>
|
field: Field<T>
|
||||||
@@ -9,6 +10,7 @@ interface CheckboxCellProps<T extends string> {
|
|||||||
onChange: (value: any) => void
|
onChange: (value: any) => void
|
||||||
hasErrors?: boolean
|
hasErrors?: boolean
|
||||||
booleanMatches?: Record<string, boolean>
|
booleanMatches?: Record<string, boolean>
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const CheckboxCell = <T extends string>({
|
const CheckboxCell = <T extends string>({
|
||||||
@@ -16,9 +18,12 @@ const CheckboxCell = <T extends string>({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
hasErrors,
|
hasErrors,
|
||||||
booleanMatches = {}
|
booleanMatches = {},
|
||||||
|
className = ''
|
||||||
}: CheckboxCellProps<T>) => {
|
}: CheckboxCellProps<T>) => {
|
||||||
const [checked, setChecked] = useState(false)
|
const [checked, setChecked] = useState(false)
|
||||||
|
// Add state for hover
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
|
||||||
// Initialize checkbox state
|
// Initialize checkbox state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -70,6 +75,12 @@ const CheckboxCell = <T extends string>({
|
|||||||
setChecked(!!value)
|
setChecked(!!value)
|
||||||
}, [value, field.fieldType, booleanMatches])
|
}, [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
|
// Handle checkbox change
|
||||||
const handleChange = useCallback((checked: boolean) => {
|
const handleChange = useCallback((checked: boolean) => {
|
||||||
setChecked(checked)
|
setChecked(checked)
|
||||||
@@ -80,11 +91,27 @@ const CheckboxCell = <T extends string>({
|
|||||||
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
|
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div
|
||||||
"flex items-center justify-center h-10 px-2 py-1 rounded-md",
|
className={cn(
|
||||||
outlineClass,
|
"flex items-center justify-center h-10 px-2 py-1 rounded-md",
|
||||||
hasErrors ? "bg-red-50 border-destructive" : "border-input"
|
outlineClass,
|
||||||
)}>
|
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
|
<Checkbox
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onCheckedChange={handleChange}
|
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
|
isMultiline?: boolean
|
||||||
isPrice?: boolean
|
isPrice?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add efficient price formatting utility
|
// Add efficient price formatting utility
|
||||||
@@ -39,7 +40,8 @@ const InputCell = <T extends string>({
|
|||||||
hasErrors,
|
hasErrors,
|
||||||
isMultiline = false,
|
isMultiline = false,
|
||||||
isPrice = false,
|
isPrice = false,
|
||||||
disabled = false
|
disabled = false,
|
||||||
|
className = ''
|
||||||
}: InputCellProps<T>) => {
|
}: InputCellProps<T>) => {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editValue, setEditValue] = useState('');
|
const [editValue, setEditValue] = useState('');
|
||||||
@@ -52,6 +54,15 @@ const InputCell = <T extends string>({
|
|||||||
// Track local display value to avoid waiting for validation
|
// Track local display value to avoid waiting for validation
|
||||||
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
|
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
|
// Initialize localDisplayValue on mount and when value changes externally
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (localDisplayValue === null ||
|
if (localDisplayValue === null ||
|
||||||
@@ -151,11 +162,26 @@ const InputCell = <T extends string>({
|
|||||||
// If disabled, just render the value without any interactivity
|
// If disabled, just render the value without any interactivity
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div
|
||||||
"px-3 py-2 h-10 rounded-md text-sm w-full",
|
className={cn(
|
||||||
outlineClass,
|
"px-3 py-2 h-10 rounded-md text-sm w-full",
|
||||||
hasErrors ? "border-destructive" : "border-input"
|
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}
|
{displayValue}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -170,6 +196,7 @@ const InputCell = <T extends string>({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
hasErrors={hasErrors}
|
hasErrors={hasErrors}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
className={className}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -188,8 +215,18 @@ const InputCell = <T extends string>({
|
|||||||
className={cn(
|
className={cn(
|
||||||
outlineClass,
|
outlineClass,
|
||||||
hasErrors ? "border-destructive" : "",
|
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
|
<div
|
||||||
@@ -199,6 +236,19 @@ const InputCell = <T extends string>({
|
|||||||
outlineClass,
|
outlineClass,
|
||||||
hasErrors ? "border-destructive" : "border-input"
|
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}
|
{displayValue}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface MultiSelectCellProps<T extends string> {
|
|||||||
hasErrors?: boolean
|
hasErrors?: boolean
|
||||||
options?: readonly FieldOption[]
|
options?: readonly FieldOption[]
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memoized option item to prevent unnecessary renders for large option lists
|
// Memoized option item to prevent unnecessary renders for large option lists
|
||||||
@@ -155,14 +156,17 @@ const MultiSelectCell = <T extends string>({
|
|||||||
onEndEdit,
|
onEndEdit,
|
||||||
hasErrors,
|
hasErrors,
|
||||||
options: providedOptions,
|
options: providedOptions,
|
||||||
disabled = false
|
disabled = false,
|
||||||
|
className = ''
|
||||||
}: MultiSelectCellProps<T>) => {
|
}: MultiSelectCellProps<T>) => {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
// Add internal state for tracking selections
|
// Add internal state for tracking selections - ensure value is always an array
|
||||||
const [internalValue, setInternalValue] = useState<string[]>(value)
|
const [internalValue, setInternalValue] = useState<string[]>(Array.isArray(value) ? value : [])
|
||||||
// Ref for the command list to enable scrolling
|
// Ref for the command list to enable scrolling
|
||||||
const commandListRef = useRef<HTMLDivElement>(null)
|
const commandListRef = useRef<HTMLDivElement>(null)
|
||||||
|
// Add state for hover
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
|
||||||
// Create a memoized Set for fast lookups of selected values
|
// Create a memoized Set for fast lookups of selected values
|
||||||
const selectedValueSet = useMemo(() => new Set(internalValue), [internalValue]);
|
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
|
// Sync internalValue with external value when component mounts or value changes externally
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setInternalValue(value)
|
// Ensure value is always an array
|
||||||
|
setInternalValue(Array.isArray(value) ? value : [])
|
||||||
}
|
}
|
||||||
}, [value, open])
|
}, [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) {
|
if (disabled) {
|
||||||
// Handle array values
|
const displayValue = internalValue.length > 0
|
||||||
const displayValue = Array.isArray(value)
|
? internalValue.map(val => {
|
||||||
? value.map(v => {
|
const option = selectOptions.find(opt => opt.value === val);
|
||||||
const option = providedOptions?.find(o => o.value === v);
|
return option ? option.label : val;
|
||||||
return option ? option.label : v;
|
|
||||||
}).join(', ')
|
}).join(', ')
|
||||||
: value;
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div
|
||||||
"w-full px-3 py-2 h-10 rounded-md text-sm flex items-center",
|
className={cn(
|
||||||
"border",
|
"w-full px-3 py-2 h-10 rounded-md text-sm flex items-center",
|
||||||
hasErrors ? "border-destructive" : "border-input"
|
"border",
|
||||||
)}>
|
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 || ""}
|
{displayValue || ""}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover open={open} onOpenChange={(o) => {
|
||||||
open={open}
|
// Only open the popover if we're not in copy down mode
|
||||||
onOpenChange={(isOpen) => {
|
if (!hasClass('!bg-blue-100') && !hasClass('!bg-blue-200') && !hasClass('hover:!bg-blue-100')) {
|
||||||
setOpen(isOpen);
|
setOpen(o);
|
||||||
handleOpenChange(isOpen);
|
handleOpenChange(o);
|
||||||
}}
|
}
|
||||||
>
|
}}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -344,8 +370,35 @@ const MultiSelectCell = <T extends string>({
|
|||||||
"w-full justify-between font-normal",
|
"w-full justify-between font-normal",
|
||||||
"border",
|
"border",
|
||||||
!internalValue.length && "text-muted-foreground",
|
!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 w-full justify-between">
|
||||||
<div className="flex items-center gap-2 overflow-hidden">
|
<div className="flex items-center gap-2 overflow-hidden">
|
||||||
@@ -411,46 +464,37 @@ const MultiSelectCell = <T extends string>({
|
|||||||
MultiSelectCell.displayName = 'MultiSelectCell';
|
MultiSelectCell.displayName = 'MultiSelectCell';
|
||||||
|
|
||||||
export default React.memo(MultiSelectCell, (prev, next) => {
|
export default React.memo(MultiSelectCell, (prev, next) => {
|
||||||
// Quick check for reference equality of simple props
|
// Check primitive props first (cheap comparisons)
|
||||||
if (prev.hasErrors !== next.hasErrors ||
|
if (prev.hasErrors !== next.hasErrors) return false;
|
||||||
prev.disabled !== next.disabled) {
|
if (prev.disabled !== next.disabled) return false;
|
||||||
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
|
// Check options (potentially expensive for large option lists)
|
||||||
if (Array.isArray(prev.value) && Array.isArray(next.value)) {
|
const prevOptions = prev.options || [];
|
||||||
if (prev.value.length !== next.value.length) return false;
|
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 option lists, just compare references
|
||||||
// For large arrays, JSON stringify is actually faster than iterating
|
if (prevOptions.length > 100) {
|
||||||
return JSON.stringify(prev.value) === JSON.stringify(next.value);
|
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
|
// For smaller lists, do a shallow comparison
|
||||||
if (prev.options !== next.options) {
|
for (let i = 0; i < prevOptions.length; i++) {
|
||||||
if (!prev.options || !next.options) return false;
|
if (prevOptions[i] !== nextOptions[i]) 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface MultilineInputProps<T extends string> {
|
|||||||
onChange: (value: any) => void
|
onChange: (value: any) => void
|
||||||
hasErrors?: boolean
|
hasErrors?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const MultilineInput = <T extends string>({
|
const MultilineInput = <T extends string>({
|
||||||
@@ -19,7 +20,8 @@ const MultilineInput = <T extends string>({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
hasErrors = false,
|
hasErrors = false,
|
||||||
disabled = false
|
disabled = false,
|
||||||
|
className = ''
|
||||||
}: MultilineInputProps<T>) => {
|
}: MultilineInputProps<T>) => {
|
||||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
const [editValue, setEditValue] = useState('');
|
const [editValue, setEditValue] = useState('');
|
||||||
@@ -27,7 +29,16 @@ const MultilineInput = <T extends string>({
|
|||||||
const cellRef = useRef<HTMLDivElement>(null);
|
const cellRef = useRef<HTMLDivElement>(null);
|
||||||
const preventReopenRef = useRef(false);
|
const preventReopenRef = useRef(false);
|
||||||
const pendingChangeRef = useRef<string | null>(null);
|
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
|
// Initialize localDisplayValue on mount and when value changes externally
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (localDisplayValue === null ||
|
if (localDisplayValue === null ||
|
||||||
@@ -123,11 +134,26 @@ const MultilineInput = <T extends string>({
|
|||||||
// If disabled, just render the value without any interactivity
|
// If disabled, just render the value without any interactivity
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div
|
||||||
"px-3 py-2 min-h-[80px] max-h-[80px] rounded-md text-sm w-full",
|
className={cn(
|
||||||
outlineClass,
|
"px-3 py-2 min-h-[80px] max-h-[80px] rounded-md text-sm w-full",
|
||||||
hasErrors ? "border-destructive" : "border-input"
|
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}
|
{displayValue}
|
||||||
</div>
|
</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",
|
"px-3 py-2 min-h-[80px] max-h-[80px] rounded-md text-sm w-full cursor-pointer",
|
||||||
"overflow-hidden whitespace-pre-wrap",
|
"overflow-hidden whitespace-pre-wrap",
|
||||||
outlineClass,
|
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}
|
{displayValue}
|
||||||
</div>
|
</div>
|
||||||
@@ -189,5 +229,6 @@ export default React.memo(MultilineInput, (prev, next) => {
|
|||||||
if (prev.disabled !== next.disabled) return false;
|
if (prev.disabled !== next.disabled) return false;
|
||||||
if (prev.field !== next.field) return false;
|
if (prev.field !== next.field) return false;
|
||||||
if (prev.value !== next.value) return false;
|
if (prev.value !== next.value) return false;
|
||||||
|
if (prev.className !== next.className) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -21,6 +21,7 @@ interface SelectCellProps<T extends string> {
|
|||||||
hasErrors?: boolean
|
hasErrors?: boolean
|
||||||
options: readonly any[]
|
options: readonly any[]
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lightweight version of the select cell with minimal dependencies
|
// Lightweight version of the select cell with minimal dependencies
|
||||||
@@ -32,7 +33,8 @@ const SelectCell = <T extends string>({
|
|||||||
onEndEdit,
|
onEndEdit,
|
||||||
hasErrors,
|
hasErrors,
|
||||||
options = [],
|
options = [],
|
||||||
disabled = false
|
disabled = false,
|
||||||
|
className = ''
|
||||||
}: SelectCellProps<T>) => {
|
}: SelectCellProps<T>) => {
|
||||||
// State for the open/closed state of the dropdown
|
// State for the open/closed state of the dropdown
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -46,6 +48,15 @@ const SelectCell = <T extends string>({
|
|||||||
// State to track if the value is being processed/validated
|
// State to track if the value is being processed/validated
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
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
|
// Update internal value when prop value changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInternalValue(value);
|
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",
|
"w-full px-3 py-2 h-10 rounded-md text-sm flex items-center",
|
||||||
"border",
|
"border",
|
||||||
hasErrors ? "border-destructive" : "border-input",
|
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 || ""}
|
{displayText || ""}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -151,8 +177,11 @@ const SelectCell = <T extends string>({
|
|||||||
<Popover
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(isOpen) => {
|
onOpenChange={(isOpen) => {
|
||||||
setOpen(isOpen);
|
// Only open the popover if we're not in copy down mode
|
||||||
if (isOpen && onStartEdit) onStartEdit();
|
if (!hasClass('!bg-blue-100') && !hasClass('!bg-blue-200') && !hasClass('hover:!bg-blue-100')) {
|
||||||
|
setOpen(isOpen);
|
||||||
|
if (isOpen && onStartEdit) onStartEdit();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -165,14 +194,36 @@ const SelectCell = <T extends string>({
|
|||||||
"border",
|
"border",
|
||||||
!internalValue && "text-muted-foreground",
|
!internalValue && "text-muted-foreground",
|
||||||
isProcessing && "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) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
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);
|
setOpen(!open);
|
||||||
if (!open && onStartEdit) onStartEdit();
|
if (!open && onStartEdit) onStartEdit();
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
<span className={isProcessing ? "opacity-70" : ""}>
|
<span className={isProcessing ? "opacity-70" : ""}>
|
||||||
{displayValue}
|
{displayValue}
|
||||||
@@ -224,6 +275,7 @@ export default React.memo(SelectCell, (prev, next) => {
|
|||||||
if (prev.value !== next.value) return false;
|
if (prev.value !== next.value) return false;
|
||||||
if (prev.hasErrors !== next.hasErrors) return false;
|
if (prev.hasErrors !== next.hasErrors) return false;
|
||||||
if (prev.disabled !== next.disabled) 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
|
// Only check options array for reference equality - we're handling deep comparison internally
|
||||||
if (prev.options !== next.options &&
|
if (prev.options !== next.options &&
|
||||||
|
|||||||
Reference in New Issue
Block a user