Improve copy down functionality with loading state and ability to select end cell instead of defaulting to the bottom

This commit is contained in:
2025-03-14 16:59:07 -04:00
parent f95c1f2d43
commit d0a83c04ca
9 changed files with 811 additions and 200 deletions

View File

@@ -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;

View File

@@ -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>
)}

View File

@@ -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];
// 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
const rowsBelow = data.slice(rowIndex + 1);
// 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(() => (

View File

@@ -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,71 +383,118 @@ const ValidationTable = <T extends string>({
}
return (
<div className="min-w-max">
<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" : ""
)}
>
{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>
<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
);
})}
</TableRow>
))}
</TableBody>
</Table>
// 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`}
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>
</CopyDownContext.Provider>
);
};

View File

@@ -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(
"flex items-center justify-center h-10 px-2 py-1 rounded-md",
outlineClass,
hasErrors ? "bg-red-50 border-destructive" : "border-input"
)}>
<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",
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;
});

View File

@@ -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(
"px-3 py-2 h-10 rounded-md text-sm w-full",
outlineClass,
hasErrors ? "border-destructive" : "border-input"
)}>
<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>

View File

@@ -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(
"w-full px-3 py-2 h-10 rounded-md text-sm flex items-center",
"border",
hasErrors ? "border-destructive" : "border-input"
)}>
<div
className={cn(
"w-full px-3 py-2 h-10 rounded-md text-sm flex items-center",
"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 || ""}
</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 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 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;
// Check options (potentially expensive for large option lists)
const prevOptions = prev.options || [];
const nextOptions = next.options || [];
if (prevOptions.length !== nextOptions.length) return false;
// For large option lists, just compare references
if (prevOptions.length > 100) {
return prevOptions === nextOptions;
}
// 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;

View File

@@ -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('');
@@ -27,7 +29,16 @@ const MultilineInput = <T extends string>({
const cellRef = useRef<HTMLDivElement>(null);
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(
"px-3 py-2 min-h-[80px] max-h-[80px] rounded-md text-sm w-full",
outlineClass,
hasErrors ? "border-destructive" : "border-input"
)}>
<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;
});

View File

@@ -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) => {
setOpen(isOpen);
if (isOpen && onStartEdit) onStartEdit();
// 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 &&