Fix text overflowing template dropdown trigger, add new MultilineInput component with popover for editing, remove MultiInputCell component except for code to create new MultiSelectCell component

This commit is contained in:
2025-03-14 00:44:44 -04:00
parent 0f89373d11
commit 0ef27a3229
7 changed files with 383 additions and 332 deletions

View File

@@ -4,12 +4,11 @@
* Red outline + alert circle icon with tooltip if cell is NOT empty and isn't valid * Red outline + alert circle icon with tooltip if cell is NOT empty and isn't valid
5. Description column needs to have an expanded view of some sort, maybe a popover to allow for easier editing 5. Description column needs to have an expanded view of some sort, maybe a popover to allow for easier editing
* Don't distort table to make it happen * Don't distort table to make it happen
7. The template select cell is expanding, needs to be fixed size and truncate
8. When you enter a value in 2+ cells before validation finishes, contents from all edited cells get erased when validation finishes 8. When you enter a value in 2+ cells before validation finishes, contents from all edited cells get erased when validation finishes
9. Import dialog state not fully reset when closing? (validate data step appears scrolled to the middle of the table where I left it) 9. Import dialog state not fully reset when closing? (validate data step appears scrolled to the middle of the table where I left it)
10. UPC column doesn't need to show loading state when Item Number is being processed, only show on item number column 10. UPC column doesn't need to show loading state when Item Number is being processed, only show on item number column
11. Copy down needs to show a loading state on the cells that it will copy to 11. Copy down needs to show a loading state on the cells that it will copy to
12. Shipping restrictions/tax category should default to ID 0 if we didn't get it elsewhere 15. Enhance copy down feature by allowing user to choose the last cell to copy to, instead of going all the way to the bottom
@@ -23,6 +22,8 @@
✅FIXED 2. Columns alignment with header is slightly off, gets worse the further right you go ✅FIXED 2. Columns alignment with header is slightly off, gets worse the further right you go
✅FIXED 3. The copy down button is in the way of the validation error icon and the select open trigger - all three need to be in unique locations ✅FIXED 3. The copy down button is in the way of the validation error icon and the select open trigger - all three need to be in unique locations
✅FIXED 6. Need to ensure all cell's contents don't overflow the input (truncate). COO does this currently, probably more ✅FIXED 6. Need to ensure all cell's contents don't overflow the input (truncate). COO does this currently, probably more
✅FIXED 7. The template select cell is expanding, needs to be fixed size and truncate
✅FIXED 12. Shipping restrictions/tax category should default to ID 0 if we didn't get it elsewhere
✅FIXED 13. Header row should be sticky (both up/down and left/right) ✅FIXED 13. Header row should be sticky (both up/down and left/right)
✅FIXED 14. Need a way to scroll around table if user doesn't have mouse wheel for left/right ✅FIXED 14. Need a way to scroll around table if user doesn't have mouse wheel for left/right

View File

@@ -172,7 +172,19 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
if (!value) return placeholder; if (!value) return placeholder;
const template = templates.find(t => t.id.toString() === value); const template = templates.find(t => t.id.toString() === value);
if (!template) return placeholder; if (!template) return placeholder;
return getTemplateDisplayText(value);
// Get the original display text
const originalText = getTemplateDisplayText(value);
// Check if it has the expected format "Brand - Product Type"
if (originalText.includes(' - ')) {
const [brand, productType] = originalText.split(' - ', 2);
// Reverse the order to "Product Type - Brand"
return `${productType} - ${brand}`;
}
// If it doesn't match the expected format, return the original text
return originalText;
} catch (err) { } catch (err) {
console.error('Error getting display text:', err); console.error('Error getting display text:', err);
return placeholder; return placeholder;
@@ -218,10 +230,10 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className={cn("w-full justify-between", triggerClassName)} className={cn("w-full justify-between overflow-hidden", triggerClassName)}
> >
{getDisplayText()} <span className="truncate overflow-hidden mr-2">{getDisplayText()}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 flex-none" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className={cn("w-[300px] p-0", className)}> <PopoverContent className={cn("w-[300px] p-0", className)}>

View File

@@ -9,7 +9,7 @@ import {
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import InputCell from './cells/InputCell' import InputCell from './cells/InputCell'
import SelectCell from './cells/SelectCell' import SelectCell from './cells/SelectCell'
import MultiInputCell from './cells/MultiInputCell' import MultiSelectCell from './cells/MultiSelectCell'
import { TableCell } from '@/components/ui/table' import { TableCell } from '@/components/ui/table'
// Define error object type // Define error object type
@@ -88,7 +88,7 @@ const BaseCellContent = React.memo(({
if (fieldType === 'multi-select' || fieldType === 'multi-input') { if (fieldType === 'multi-select' || fieldType === 'multi-input') {
return ( return (
<MultiInputCell <MultiSelectCell
field={field} field={field}
value={value} value={value}
onChange={onChange} onChange={onChange}

View File

@@ -67,9 +67,9 @@ const MemoizedTemplateSelect = React.memo(({
}) => { }) => {
if (isLoading) { if (isLoading) {
return ( return (
<Button variant="outline" className="w-full justify-between" disabled> <Button variant="outline" className="w-full justify-between overflow-hidden" disabled>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin flex-none" />
Loading... <span className="truncate overflow-hidden">Loading...</span>
</Button> </Button>
); );
} }
@@ -217,15 +217,17 @@ const ValidationTable = <T extends string>({
const rowIndex = data.findIndex(r => r === row.original); const rowIndex = data.findIndex(r => r === row.original);
return ( return (
<TableCell className="p-1" style={{ width: '200px', minWidth: '200px', maxWidth: '200px' }}> <TableCell className="p-1" style={{ width: '200px', minWidth: '200px', maxWidth: '200px', overflow: 'hidden' }}>
<MemoizedTemplateSelect <div className="w-full overflow-hidden">
templates={templates} <MemoizedTemplateSelect
value={templateValue || ''} templates={templates}
onValueChange={(value) => handleTemplateChange(value, rowIndex)} value={templateValue || ''}
getTemplateDisplayText={getTemplateDisplayText} onValueChange={(value) => handleTemplateChange(value, rowIndex)}
defaultBrand={defaultBrand} getTemplateDisplayText={getTemplateDisplayText}
isLoading={isLoadingTemplates} defaultBrand={defaultBrand}
/> isLoading={isLoadingTemplates}
/>
</div>
</TableCell> </TableCell>
); );
} }

View File

@@ -1,8 +1,8 @@
import React, { useState, useCallback, useDeferredValue, useTransition, useRef, useEffect } from 'react' import React, { useState, useCallback, useDeferredValue, useTransition, useRef, useEffect, useMemo } from 'react'
import { Field } from '../../../../types' import { Field } from '../../../../types'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import MultilineInput from './MultilineInput'
interface InputCellProps<T extends string> { interface InputCellProps<T extends string> {
field: Field<T> field: Field<T>
@@ -31,6 +31,7 @@ const formatPrice = (value: string): string => {
}; };
const InputCell = <T extends string>({ const InputCell = <T extends string>({
field,
value, value,
onChange, onChange,
onStartEdit, onStartEdit,
@@ -48,6 +49,18 @@ const InputCell = <T extends string>({
// Use a ref to track if we need to process the value // Use a ref to track if we need to process the value
const needsProcessingRef = useRef(false); const needsProcessingRef = useRef(false);
// Track local display value to avoid waiting for validation
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
// Initialize localDisplayValue on mount and when value changes externally
useEffect(() => {
if (localDisplayValue === null ||
(typeof value === 'string' && typeof localDisplayValue === 'string' &&
value.trim() !== localDisplayValue.trim())) {
setLocalDisplayValue(value);
}
}, [value, localDisplayValue]);
// Efficiently handle price formatting without multiple rerenders // Efficiently handle price formatting without multiple rerenders
useEffect(() => { useEffect(() => {
if (isPrice && needsProcessingRef.current && !isEditing) { if (isPrice && needsProcessingRef.current && !isEditing) {
@@ -93,6 +106,9 @@ const InputCell = <T extends string>({
needsProcessingRef.current = true; needsProcessingRef.current = true;
} }
// Update local display value immediately
setLocalDisplayValue(processedValue);
onChange(processedValue); onChange(processedValue);
onEndEdit?.(); onEndEdit?.();
}); });
@@ -104,14 +120,30 @@ const InputCell = <T extends string>({
setEditValue(newValue); setEditValue(newValue);
}, [isPrice]); }, [isPrice]);
// Display value with efficient memoization // Get the display value - prioritize local display value
const displayValue = useDeferredValue( const displayValue = useMemo(() => {
isPrice && value ? // First priority: local display value (for immediate updates)
typeof value === 'number' ? value.toFixed(2) : if (localDisplayValue !== null) {
typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value) ? parseFloat(value).toFixed(2) : if (isPrice) {
value : // Format price value
value ?? '' const numValue = parseFloat(localDisplayValue);
); return !isNaN(numValue) ? numValue.toFixed(2) : localDisplayValue;
}
return localDisplayValue;
}
// Second priority: handle price formatting for the actual value
if (isPrice && value) {
if (typeof value === 'number') {
return value.toFixed(2);
} else if (typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value)) {
return parseFloat(value).toFixed(2);
}
}
// Default: use the actual value or empty string
return value ?? '';
}, [isPrice, value, localDisplayValue]);
// Add outline even when not in focus // Add outline even when not in focus
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"; const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0";
@@ -128,48 +160,48 @@ const InputCell = <T extends string>({
</div> </div>
); );
} }
// Render multiline fields using the dedicated MultilineInput component
if (isMultiline) {
return (
<MultilineInput
field={field}
value={value}
onChange={onChange}
hasErrors={hasErrors}
disabled={disabled}
/>
);
}
// Original component for non-multiline fields
return ( return (
<div className="w-full"> <div className="w-full">
{isMultiline ? ( {isEditing ? (
<Textarea <Input
value={isEditing ? editValue : (value ?? '')} type="text"
value={editValue}
onChange={handleChange} onChange={handleChange}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
autoFocus
className={cn( className={cn(
"min-h-[80px] resize-none",
outlineClass, outlineClass,
hasErrors ? "border-destructive" : "" hasErrors ? "border-destructive" : "",
isPending ? "opacity-50" : ""
)} )}
/> />
) : ( ) : (
isEditing ? ( <div
<Input onClick={handleFocus}
type="text" className={cn(
value={editValue} "px-3 py-2 h-10 rounded-md text-sm w-full cursor-text flex items-center",
onChange={handleChange} outlineClass,
onFocus={handleFocus} hasErrors ? "border-destructive" : "border-input"
onBlur={handleBlur} )}
autoFocus >
className={cn( {displayValue}
outlineClass, </div>
hasErrors ? "border-destructive" : "",
isPending ? "opacity-50" : ""
)}
/>
) : (
<div
onClick={handleFocus}
className={cn(
"px-3 py-2 h-10 rounded-md text-sm w-full cursor-text flex items-center",
outlineClass,
hasErrors ? "border-destructive" : "border-input"
)}
>
{displayValue}
</div>
)
)} )}
</div> </div>
) )
@@ -181,6 +213,7 @@ export default React.memo(InputCell, (prev, next) => {
if (prev.isMultiline !== next.isMultiline) return false; if (prev.isMultiline !== next.isMultiline) return false;
if (prev.isPrice !== next.isPrice) return false; if (prev.isPrice !== next.isPrice) return false;
if (prev.disabled !== next.disabled) return false; if (prev.disabled !== next.disabled) return false;
if (prev.field !== next.field) return false;
// Only check value if not editing (to avoid expensive rerender during editing) // Only check value if not editing (to avoid expensive rerender during editing)
if (prev.value !== next.value) { if (prev.value !== next.value) {

View File

@@ -1,6 +1,5 @@
import React, { useState, useCallback, useMemo, useEffect, useRef, useLayoutEffect } from 'react' import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'
import { Field } from '../../../../types' import { Field } from '../../../../types'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@@ -14,23 +13,17 @@ interface FieldOption {
value: string; value: string;
} }
interface MultiSelectCellProps<T extends string> {
interface MultiInputCellProps<T extends string> {
field: Field<T> field: Field<T>
value: string[] value: string[]
onChange: (value: string[]) => void onChange: (value: string[]) => void
onStartEdit?: () => void onStartEdit?: () => void
onEndEdit?: () => void onEndEdit?: () => void
hasErrors?: boolean hasErrors?: boolean
separator?: string
isMultiline?: boolean
isPrice?: boolean
options?: readonly FieldOption[] options?: readonly FieldOption[]
disabled?: boolean disabled?: boolean
} }
// Add global CSS to ensure fixed width constraints - use !important to override other styles
// Memoized option item to prevent unnecessary renders for large option lists // Memoized option item to prevent unnecessary renders for large option lists
const OptionItem = React.memo(({ const OptionItem = React.memo(({
option, option,
@@ -154,19 +147,16 @@ const VirtualizedOptions = React.memo(({
VirtualizedOptions.displayName = 'VirtualizedOptions'; VirtualizedOptions.displayName = 'VirtualizedOptions';
const MultiInputCell = <T extends string>({ const MultiSelectCell = <T extends string>({
field, field,
value = [], value = [],
onChange, onChange,
onStartEdit, onStartEdit,
onEndEdit, onEndEdit,
hasErrors, hasErrors,
separator = ',',
isMultiline = false,
isPrice = false,
options: providedOptions, options: providedOptions,
disabled = false disabled = false
}: MultiInputCellProps<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
@@ -186,8 +176,6 @@ const MultiInputCell = <T extends string>({
// Handle open state changes with improved responsiveness // Handle open state changes with improved responsiveness
const handleOpenChange = useCallback((newOpen: boolean) => { const handleOpenChange = useCallback((newOpen: boolean) => {
setOpen(newOpen);
if (open && !newOpen) { if (open && !newOpen) {
// Only update parent state when dropdown closes // Only update parent state when dropdown closes
// Avoid expensive deep comparison if lengths are different // Avoid expensive deep comparison if lengths are different
@@ -196,7 +184,7 @@ const MultiInputCell = <T extends string>({
onChange(internalValue); onChange(internalValue);
} }
if (onEndEdit) onEndEdit(); if (onEndEdit) onEndEdit();
} else if (newOpen) { } else if (newOpen && !open) {
// Sync internal state with external state when opening // Sync internal state with external state when opening
setInternalValue(value); setInternalValue(value);
setSearchQuery(""); // Reset search query on open setSearchQuery(""); // Reset search query on open
@@ -310,33 +298,6 @@ const MultiInputCell = <T extends string>({
}); });
}, []); }, []);
// Handle focus
const handleFocus = useCallback(() => {
if (onStartEdit) onStartEdit();
}, [onStartEdit]);
// Handle blur
const handleBlur = useCallback(() => {
if (onEndEdit) onEndEdit();
}, [onEndEdit]);
// Handle direct input changes
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const newValue = e.target.value.split(separator).map(v => v.trim()).filter(Boolean);
setInternalValue(newValue);
onChange(newValue);
}, [separator, onChange]);
// Format display values for price
const getDisplayValues = useCallback(() => {
if (!isPrice) return selectedValues.map(v => v.label);
return selectedValues.map(v => {
const numValue = parseFloat(v.value.replace(/[^\d.]/g, ''))
return !isNaN(numValue) ? `$${numValue.toFixed(2)}` : v.value
});
}, [selectedValues, isPrice]);
// Handle wheel scroll in dropdown // Handle wheel scroll in dropdown
const handleWheel = useCallback((e: React.WheelEvent) => { const handleWheel = useCallback((e: React.WheelEvent) => {
if (commandListRef.current) { if (commandListRef.current) {
@@ -345,9 +306,6 @@ const MultiInputCell = <T extends string>({
} }
}, []); }, []);
// Add outline even when not in focus
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
// If disabled, render a static view // If disabled, render a static view
if (disabled) { if (disabled) {
// Handle array values // Handle array values
@@ -369,241 +327,93 @@ const MultiInputCell = <T extends string>({
); );
} }
// If we have a multi-select field with options, use command UI
if (field.fieldType.type === 'multi-select' && selectOptions.length > 0) {
return (
<Popover
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
handleOpenChange(isOpen);
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"w-full justify-between font-normal",
"border",
!internalValue.length && "text-muted-foreground",
hasErrors ? "border-destructive" : ""
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen(!open);
if (!open && onStartEdit) onStartEdit();
}}
>
<div className="flex items-center w-full justify-between">
<div className="flex items-center gap-2 overflow-hidden">
{internalValue.length === 0 ? (
<span className="text-muted-foreground truncate w-full">Select...</span>
) : internalValue.length === 1 ? (
<span className="truncate w-full">{selectedValues[0].label}</span>
) : (
<>
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
{internalValue.length} selected
</Badge>
<span className="truncate">
{selectedValues.map(v => v.label).join(', ')}
</span>
</>
)}
</div>
<ChevronsUpDown className="mx-2 h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0 w-[var(--radix-popover-trigger-width)]"
align="start"
sideOffset={4}
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search..."
className="h-9"
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList
ref={commandListRef}
onWheel={handleWheel}
className="max-h-[200px]"
>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{sortedOptions.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => handleSelect(option.value)}
className="cursor-pointer"
>
{option.label}
{selectedValueSet.has(option.value) && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// For standard multi-input without options, use text input
// Ensure the non-dropdown version also respects the width constraints
if (field.width) {
const cellWidth = field.width;
// Create a reference to the container element
const containerRef = useRef<HTMLDivElement>(null);
// Create a key-value map for inline styles with fixed width - simplified
// Use layout effect more efficiently - only for the button element
// since the container already uses inline styles
useLayoutEffect(() => {
// Skip if no width specified
if (!cellWidth) return;
// Cache previous width to avoid unnecessary DOM updates
const prevWidth = containerRef.current?.getAttribute('data-prev-width');
// Only update if width changed
if (prevWidth !== String(cellWidth) && containerRef.current) {
// Store new width for next comparison
containerRef.current.setAttribute('data-prev-width', String(cellWidth));
// Only manipulate the button element directly since we can't
// reliably style it with CSS in all cases
const button = containerRef.current.querySelector('button');
if (button) {
const htmlButton = button as HTMLElement;
htmlButton.style.width = `${cellWidth}px`;
htmlButton.style.minWidth = `${cellWidth}px`;
htmlButton.style.maxWidth = `${cellWidth}px`;
htmlButton.style.boxSizing = 'border-box';
}
}
}, [cellWidth]);
return (
<div
ref={containerRef}
className="inline-block fixed-width-cell"
style={{
width: `${cellWidth}px`,
minWidth: `${cellWidth}px`,
maxWidth: `${cellWidth}px`,
boxSizing: 'border-box',
}}
data-width={cellWidth}
>
{isMultiline ? (
<Textarea
value={internalValue.join(separator)}
onChange={handleInputChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={`Enter values separated by ${separator}`}
className={cn(
"min-h-[80px] resize-none",
outlineClass,
hasErrors ? "border-destructive" : ""
)}
style={{
width: `${cellWidth}px`,
minWidth: `${cellWidth}px`,
maxWidth: `${cellWidth}px`,
boxSizing: 'border-box',
}}
/>
) : (
<div
className={cn(
"flex h-9 rounded-md border px-3 py-1 text-sm",
"cursor-text truncate",
outlineClass,
hasErrors ? "border-destructive" : "",
"overflow-hidden items-center",
"w-full"
)}
onClick={handleFocus}
style={{
width: `${cellWidth}px`,
minWidth: `${cellWidth}px`,
maxWidth: `${cellWidth}px`,
boxSizing: 'border-box',
}}
>
{internalValue.length > 0 ? getDisplayValues().join(`, `) : (
<span className="text-muted-foreground truncate">
{`Enter values separated by ${separator}`}
</span>
)}
</div>
)}
</div>
);
}
// Fallback to default behavior if no width is specified
return ( return (
<div className="w-full overflow-hidden" style={{ boxSizing: 'border-box' }}> <Popover
{isMultiline ? ( open={open}
<Textarea onOpenChange={(isOpen) => {
value={internalValue.join(separator)} setOpen(isOpen);
onChange={handleInputChange} handleOpenChange(isOpen);
onFocus={handleFocus} }}
onBlur={handleBlur} >
placeholder={`Enter values separated by ${separator}`} <PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn( className={cn(
"min-h-[80px] resize-none w-full", "w-full justify-between font-normal",
outlineClass, "border",
!internalValue.length && "text-muted-foreground",
hasErrors ? "border-destructive" : "" hasErrors ? "border-destructive" : ""
)} )}
style={{ boxSizing: 'border-box' }}
/>
) : (
<div
className={cn(
"flex h-9 w-full rounded-md border px-3 py-1 text-sm",
"cursor-text truncate", // Add truncate for text overflow
outlineClass,
hasErrors ? "border-destructive" : "",
"overflow-hidden items-center"
)}
onClick={handleFocus}
style={{ boxSizing: 'border-box' }}
> >
{internalValue.length > 0 ? getDisplayValues().join(`, `) : ( <div className="flex items-center w-full justify-between">
<span className="text-muted-foreground truncate w-full"> <div className="flex items-center gap-2 overflow-hidden">
{`Enter values separated by ${separator}`} {internalValue.length === 0 ? (
</span> <span className="text-muted-foreground truncate w-full">Select...</span>
)} ) : internalValue.length === 1 ? (
</div> <span className="truncate w-full">{selectedValues[0].label}</span>
)} ) : (
</div> <>
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
{internalValue.length} selected
</Badge>
<span className="truncate">
{selectedValues.map(v => v.label).join(', ')}
</span>
</>
)}
</div>
<ChevronsUpDown className="mx-2 h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0 w-[var(--radix-popover-trigger-width)]"
align="start"
sideOffset={4}
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search..."
className="h-9"
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList
ref={commandListRef}
onWheel={handleWheel}
className="max-h-[200px]"
>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{sortedOptions.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => handleSelect(option.value)}
className="cursor-pointer"
>
{option.label}
{selectedValueSet.has(option.value) && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
); );
}; };
MultiInputCell.displayName = 'MultiInputCell'; MultiSelectCell.displayName = 'MultiSelectCell';
export default React.memo(MultiInputCell, (prev, next) => { export default React.memo(MultiSelectCell, (prev, next) => {
// Quick check for reference equality of simple props // Quick check for reference equality of simple props
if (prev.hasErrors !== next.hasErrors || if (prev.hasErrors !== next.hasErrors ||
prev.disabled !== next.disabled || prev.disabled !== next.disabled) {
prev.isMultiline !== next.isMultiline ||
prev.isPrice !== next.isPrice ||
prev.separator !== next.separator) {
return false; return false;
} }

View File

@@ -0,0 +1,193 @@
import React, { useState, useCallback, useRef, useEffect } from 'react'
import { Field } from '../../../../types'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface MultilineInputProps<T extends string> {
field: Field<T>
value: any
onChange: (value: any) => void
hasErrors?: boolean
disabled?: boolean
}
const MultilineInput = <T extends string>({
field,
value,
onChange,
hasErrors = false,
disabled = false
}: MultilineInputProps<T>) => {
const [popoverOpen, setPopoverOpen] = useState(false);
const [editValue, setEditValue] = useState('');
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
const cellRef = useRef<HTMLDivElement>(null);
const preventReopenRef = useRef(false);
const pendingChangeRef = useRef<string | null>(null);
// Initialize localDisplayValue on mount and when value changes externally
useEffect(() => {
if (localDisplayValue === null ||
(typeof value === 'string' && typeof localDisplayValue === 'string' &&
value.trim() !== localDisplayValue.trim())) {
setLocalDisplayValue(value);
}
}, [value, localDisplayValue]);
// Process any pending changes in the background
useEffect(() => {
if (pendingChangeRef.current !== null && !popoverOpen) {
const newValue = pendingChangeRef.current;
pendingChangeRef.current = null;
// Apply changes after the popover is closed
if (newValue !== value) {
onChange(newValue);
}
}
}, [popoverOpen, onChange, value]);
// Handle trigger click to toggle the popover
const handleTriggerClick = useCallback((e: React.MouseEvent) => {
if (preventReopenRef.current) {
e.preventDefault();
e.stopPropagation();
preventReopenRef.current = false;
return;
}
// Only process if not already open
if (!popoverOpen) {
setPopoverOpen(true);
// Initialize edit value from the current display
setEditValue(localDisplayValue || value || '');
}
}, [popoverOpen, value, localDisplayValue]);
// Handle immediate close of popover
const handleClosePopover = useCallback(() => {
// Only process if we have changes
if (editValue !== value || editValue !== localDisplayValue) {
// Store pending changes for async processing
pendingChangeRef.current = editValue;
// Update local display immediately
setLocalDisplayValue(editValue);
// Queue up the change to be processed in the background
setTimeout(() => {
onChange(editValue);
}, 0);
}
// Immediately close popover
setPopoverOpen(false);
// Prevent reopening
preventReopenRef.current = true;
setTimeout(() => {
preventReopenRef.current = false;
}, 100);
}, [editValue, value, localDisplayValue, onChange]);
// Handle clicking outside the popover
const handleInteractOutside = useCallback(() => {
handleClosePopover();
}, [handleClosePopover]);
// Handle popover open/close
const handlePopoverOpenChange = useCallback((open: boolean) => {
if (!open && popoverOpen) {
// Just call the close handler
handleClosePopover();
} else if (open && !popoverOpen) {
// When opening, set edit value from current display
setEditValue(localDisplayValue || value || '');
setPopoverOpen(true);
}
}, [value, popoverOpen, handleClosePopover, localDisplayValue]);
// Handle direct input change
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setEditValue(e.target.value);
}, []);
// Calculate display value
const displayValue = localDisplayValue !== null ? localDisplayValue : (value ?? '');
// Add outline even when not in focus
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0";
// 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"
)}>
{displayValue}
</div>
);
}
return (
<div className="w-full" ref={cellRef}>
<Popover open={popoverOpen} onOpenChange={handlePopoverOpenChange}>
<PopoverTrigger asChild>
<div
onClick={handleTriggerClick}
className={cn(
"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"
)}
>
{displayValue}
</div>
</PopoverTrigger>
<PopoverContent
className="p-0 shadow-lg rounded-md"
style={{ width: cellRef.current?.offsetWidth || 'auto' }}
align="start"
side="bottom"
alignOffset={0}
sideOffset={-80}
avoidCollisions={false}
onInteractOutside={handleInteractOutside}
forceMount
>
<div className="flex flex-col">
<Button
size="icon"
variant="ghost"
onClick={handleClosePopover}
className="h-6 w-6 text-muted-foreground absolute top-0.5 right-0.5"
>
<X className="h-3 w-3" />
</Button>
<Textarea
value={editValue}
onChange={handleChange}
className="min-h-[200px] border-none focus-visible:ring-0 rounded-none p-2"
placeholder={`Enter ${field.label || 'text'}...`}
autoFocus
/>
</div>
</PopoverContent>
</Popover>
</div>
);
};
export default React.memo(MultilineInput, (prev, next) => {
if (prev.hasErrors !== next.hasErrors) return false;
if (prev.disabled !== next.disabled) return false;
if (prev.field !== next.field) return false;
if (prev.value !== next.value) return false;
return true;
});