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

View File

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

View File

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

View File

@@ -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,7 +217,8 @@ 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' }}>
<TableCell className="p-1" style={{ width: '200px', minWidth: '200px', maxWidth: '200px', overflow: 'hidden' }}>
<div className="w-full overflow-hidden">
<MemoizedTemplateSelect
templates={templates}
value={templateValue || ''}
@@ -226,6 +227,7 @@ const ValidationTable = <T extends string>({
defaultBrand={defaultBrand}
isLoading={isLoadingTemplates}
/>
</div>
</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 { 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,22 +161,23 @@ 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 ?? '')}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
className={cn(
"min-h-[80px] resize-none",
outlineClass,
hasErrors ? "border-destructive" : ""
)}
/>
) : (
isEditing ? (
{isEditing ? (
<Input
type="text"
value={editValue}
@@ -169,7 +202,6 @@ const InputCell = <T extends string>({
>
{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) {

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 { 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,8 +327,6 @@ 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}
@@ -390,12 +346,6 @@ const MultiInputCell = <T extends string>({
!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">
@@ -456,154 +406,14 @@ const MultiInputCell = <T extends string>({
</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}`}
className={cn(
"min-h-[80px] resize-none w-full",
outlineClass,
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>
);
};
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;
}

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