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
|
* 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
|
||||||
|
|
||||||
|
|||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,7 +217,8 @@ 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' }}>
|
||||||
|
<div className="w-full overflow-hidden">
|
||||||
<MemoizedTemplateSelect
|
<MemoizedTemplateSelect
|
||||||
templates={templates}
|
templates={templates}
|
||||||
value={templateValue || ''}
|
value={templateValue || ''}
|
||||||
@@ -226,6 +227,7 @@ const ValidationTable = <T extends string>({
|
|||||||
defaultBrand={defaultBrand}
|
defaultBrand={defaultBrand}
|
||||||
isLoading={isLoadingTemplates}
|
isLoading={isLoadingTemplates}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</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 { 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";
|
||||||
@@ -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 (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{isMultiline ? (
|
{isEditing ? (
|
||||||
<Textarea
|
|
||||||
value={isEditing ? editValue : (value ?? '')}
|
|
||||||
onChange={handleChange}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
className={cn(
|
|
||||||
"min-h-[80px] resize-none",
|
|
||||||
outlineClass,
|
|
||||||
hasErrors ? "border-destructive" : ""
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
isEditing ? (
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={editValue}
|
value={editValue}
|
||||||
@@ -169,7 +202,6 @@ const InputCell = <T extends string>({
|
|||||||
>
|
>
|
||||||
{displayValue}
|
{displayValue}
|
||||||
</div>
|
</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) {
|
||||||
|
|||||||
@@ -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,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 (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
@@ -390,12 +346,6 @@ const MultiInputCell = <T extends string>({
|
|||||||
!internalValue.length && "text-muted-foreground",
|
!internalValue.length && "text-muted-foreground",
|
||||||
hasErrors ? "border-destructive" : ""
|
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 w-full justify-between">
|
||||||
<div className="flex items-center gap-2 overflow-hidden">
|
<div className="flex items-center gap-2 overflow-hidden">
|
||||||
@@ -456,154 +406,14 @@ const MultiInputCell = <T extends string>({
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</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
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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