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:
@@ -4,12 +4,11 @@
|
||||
* 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
|
||||
* 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
|
||||
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
|
||||
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 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 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 14. Need a way to scroll around table if user doesn't have mouse wheel for left/right
|
||||
|
||||
|
||||
@@ -172,7 +172,19 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
|
||||
if (!value) return placeholder;
|
||||
const template = templates.find(t => t.id.toString() === value);
|
||||
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) {
|
||||
console.error('Error getting display text:', err);
|
||||
return placeholder;
|
||||
@@ -218,10 +230,10 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn("w-full justify-between", triggerClassName)}
|
||||
className={cn("w-full justify-between overflow-hidden", triggerClassName)}
|
||||
>
|
||||
{getDisplayText()}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<span className="truncate overflow-hidden mr-2">{getDisplayText()}</span>
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 flex-none" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className={cn("w-[300px] p-0", className)}>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@/components/ui/tooltip'
|
||||
import InputCell from './cells/InputCell'
|
||||
import SelectCell from './cells/SelectCell'
|
||||
import MultiInputCell from './cells/MultiInputCell'
|
||||
import MultiSelectCell from './cells/MultiSelectCell'
|
||||
import { TableCell } from '@/components/ui/table'
|
||||
|
||||
// Define error object type
|
||||
@@ -88,7 +88,7 @@ const BaseCellContent = React.memo(({
|
||||
|
||||
if (fieldType === 'multi-select' || fieldType === 'multi-input') {
|
||||
return (
|
||||
<MultiInputCell
|
||||
<MultiSelectCell
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -67,9 +67,9 @@ const MemoizedTemplateSelect = React.memo(({
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Button variant="outline" className="w-full justify-between" disabled>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Loading...
|
||||
<Button variant="outline" className="w-full justify-between overflow-hidden" disabled>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin flex-none" />
|
||||
<span className="truncate overflow-hidden">Loading...</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -217,15 +217,17 @@ const ValidationTable = <T extends string>({
|
||||
const rowIndex = data.findIndex(r => r === row.original);
|
||||
|
||||
return (
|
||||
<TableCell className="p-1" style={{ width: '200px', minWidth: '200px', maxWidth: '200px' }}>
|
||||
<MemoizedTemplateSelect
|
||||
templates={templates}
|
||||
value={templateValue || ''}
|
||||
onValueChange={(value) => handleTemplateChange(value, rowIndex)}
|
||||
getTemplateDisplayText={getTemplateDisplayText}
|
||||
defaultBrand={defaultBrand}
|
||||
isLoading={isLoadingTemplates}
|
||||
/>
|
||||
<TableCell className="p-1" style={{ width: '200px', minWidth: '200px', maxWidth: '200px', overflow: 'hidden' }}>
|
||||
<div className="w-full overflow-hidden">
|
||||
<MemoizedTemplateSelect
|
||||
templates={templates}
|
||||
value={templateValue || ''}
|
||||
onValueChange={(value) => handleTemplateChange(value, rowIndex)}
|
||||
getTemplateDisplayText={getTemplateDisplayText}
|
||||
defaultBrand={defaultBrand}
|
||||
isLoading={isLoadingTemplates}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
import MultilineInput from './MultilineInput'
|
||||
|
||||
interface InputCellProps<T extends string> {
|
||||
field: Field<T>
|
||||
@@ -31,6 +31,7 @@ const formatPrice = (value: string): string => {
|
||||
};
|
||||
|
||||
const InputCell = <T extends string>({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
onStartEdit,
|
||||
@@ -48,6 +49,18 @@ const InputCell = <T extends string>({
|
||||
// Use a ref to track if we need to process the value
|
||||
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
|
||||
useEffect(() => {
|
||||
if (isPrice && needsProcessingRef.current && !isEditing) {
|
||||
@@ -93,6 +106,9 @@ const InputCell = <T extends string>({
|
||||
needsProcessingRef.current = true;
|
||||
}
|
||||
|
||||
// Update local display value immediately
|
||||
setLocalDisplayValue(processedValue);
|
||||
|
||||
onChange(processedValue);
|
||||
onEndEdit?.();
|
||||
});
|
||||
@@ -104,14 +120,30 @@ const InputCell = <T extends string>({
|
||||
setEditValue(newValue);
|
||||
}, [isPrice]);
|
||||
|
||||
// Display value with efficient memoization
|
||||
const displayValue = useDeferredValue(
|
||||
isPrice && value ?
|
||||
typeof value === 'number' ? value.toFixed(2) :
|
||||
typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value) ? parseFloat(value).toFixed(2) :
|
||||
value :
|
||||
value ?? ''
|
||||
);
|
||||
// Get the display value - prioritize local display value
|
||||
const displayValue = useMemo(() => {
|
||||
// First priority: local display value (for immediate updates)
|
||||
if (localDisplayValue !== null) {
|
||||
if (isPrice) {
|
||||
// Format price 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
|
||||
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0";
|
||||
@@ -129,47 +161,47 @@ const InputCell = <T extends string>({
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="w-full">
|
||||
{isMultiline ? (
|
||||
<Textarea
|
||||
value={isEditing ? editValue : (value ?? '')}
|
||||
{isEditing ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
autoFocus
|
||||
className={cn(
|
||||
"min-h-[80px] resize-none",
|
||||
outlineClass,
|
||||
hasErrors ? "border-destructive" : ""
|
||||
hasErrors ? "border-destructive" : "",
|
||||
isPending ? "opacity-50" : ""
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
isEditing ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
autoFocus
|
||||
className={cn(
|
||||
outlineClass,
|
||||
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
|
||||
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>
|
||||
)
|
||||
@@ -181,6 +213,7 @@ export default React.memo(InputCell, (prev, next) => {
|
||||
if (prev.isMultiline !== next.isMultiline) return false;
|
||||
if (prev.isPrice !== next.isPrice) 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)
|
||||
if (prev.value !== next.value) {
|
||||
|
||||
@@ -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 { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
@@ -14,23 +13,17 @@ interface FieldOption {
|
||||
value: string;
|
||||
}
|
||||
|
||||
|
||||
interface MultiInputCellProps<T extends string> {
|
||||
interface MultiSelectCellProps<T extends string> {
|
||||
field: Field<T>
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
onStartEdit?: () => void
|
||||
onEndEdit?: () => void
|
||||
hasErrors?: boolean
|
||||
separator?: string
|
||||
isMultiline?: boolean
|
||||
isPrice?: boolean
|
||||
options?: readonly FieldOption[]
|
||||
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
|
||||
const OptionItem = React.memo(({
|
||||
option,
|
||||
@@ -154,19 +147,16 @@ const VirtualizedOptions = React.memo(({
|
||||
|
||||
VirtualizedOptions.displayName = 'VirtualizedOptions';
|
||||
|
||||
const MultiInputCell = <T extends string>({
|
||||
const MultiSelectCell = <T extends string>({
|
||||
field,
|
||||
value = [],
|
||||
onChange,
|
||||
onStartEdit,
|
||||
onEndEdit,
|
||||
hasErrors,
|
||||
separator = ',',
|
||||
isMultiline = false,
|
||||
isPrice = false,
|
||||
options: providedOptions,
|
||||
disabled = false
|
||||
}: MultiInputCellProps<T>) => {
|
||||
}: MultiSelectCellProps<T>) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
// Add internal state for tracking selections
|
||||
@@ -186,8 +176,6 @@ const MultiInputCell = <T extends string>({
|
||||
|
||||
// Handle open state changes with improved responsiveness
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
setOpen(newOpen);
|
||||
|
||||
if (open && !newOpen) {
|
||||
// Only update parent state when dropdown closes
|
||||
// Avoid expensive deep comparison if lengths are different
|
||||
@@ -196,7 +184,7 @@ const MultiInputCell = <T extends string>({
|
||||
onChange(internalValue);
|
||||
}
|
||||
if (onEndEdit) onEndEdit();
|
||||
} else if (newOpen) {
|
||||
} else if (newOpen && !open) {
|
||||
// Sync internal state with external state when opening
|
||||
setInternalValue(value);
|
||||
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
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
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) {
|
||||
// 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 (
|
||||
<div className="w-full overflow-hidden" style={{ boxSizing: 'border-box' }}>
|
||||
{isMultiline ? (
|
||||
<Textarea
|
||||
value={internalValue.join(separator)}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder={`Enter values separated by ${separator}`}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
handleOpenChange(isOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"min-h-[80px] resize-none w-full",
|
||||
outlineClass,
|
||||
"w-full justify-between font-normal",
|
||||
"border",
|
||||
!internalValue.length && "text-muted-foreground",
|
||||
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(`, `) : (
|
||||
<span className="text-muted-foreground truncate w-full">
|
||||
{`Enter values separated by ${separator}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
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
|
||||
if (prev.hasErrors !== next.hasErrors ||
|
||||
prev.disabled !== next.disabled ||
|
||||
prev.isMultiline !== next.isMultiline ||
|
||||
prev.isPrice !== next.isPrice ||
|
||||
prev.separator !== next.separator) {
|
||||
prev.disabled !== next.disabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user