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

View File

@@ -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(() => (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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