Get multi select popover to stay open

This commit is contained in:
2025-02-20 01:38:59 -05:00
parent 468f85c45d
commit bba7362641
2 changed files with 170 additions and 122 deletions

View File

@@ -92,7 +92,6 @@ export type Column<T extends string> =
export type Columns<T extends string> = Column<T>[]
type ReadonlyField<T extends string> = TsDeepReadonly<Field<T>>;
export const MatchColumnsStep = <T extends string>({
data,

View File

@@ -56,6 +56,12 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import * as Select from "@radix-ui/react-select"
import {
Dialog,
DialogContent,
DialogTrigger,
} from "@/components/ui/dialog"
type Props<T extends string> = {
initialData: (Data<T> & Meta)[]
@@ -73,20 +79,60 @@ type CellProps = {
const EditableCell = ({ value, onChange, error, field }: CellProps) => {
const [isEditing, setIsEditing] = useState(false)
const [inputValue, setInputValue] = useState(value ?? "")
const [validationError, setValidationError] = useState<{level: string, message: string} | undefined>(error)
const [searchQuery, setSearchQuery] = useState("")
const [localValues, setLocalValues] = useState<string[]>([])
// Update input value when external value changes and we're not editing
useEffect(() => {
if (!isEditing) {
setInputValue(value ?? "")
}
}, [value, isEditing])
// Keep localValues in sync with value for multi-select
useEffect(() => {
if (field.fieldType.type === "multi-select") {
const selectedValues = Array.isArray(value) ? value : value ? [value] : []
setLocalValues(selectedValues)
}
}, [value, field.fieldType.type])
const validateRegex = (val: any) => {
// Handle non-string values
if (val === undefined || val === null) return undefined
if (Array.isArray(val)) {
// For arrays (multi-select), join values with comma
val = val.join(", ")
}
if (typeof val === "boolean") {
// For booleans, convert to "Yes"/"No"
val = val ? "Yes" : "No"
}
// Convert to string and check if empty/whitespace
const strVal = String(val)
if (!strVal || !strVal.trim()) return undefined
const validateRegex = (val: string) => {
const regexValidation = field.validations?.find(v => v.rule === "regex")
if (regexValidation && val) {
if (regexValidation) {
const regex = new RegExp(regexValidation.value, regexValidation.flags)
if (!regex.test(val)) {
if (!regex.test(strVal)) {
return { level: regexValidation.level || "error", message: regexValidation.errorMessage }
}
}
return undefined
}
// Only show validation errors when not editing and value is invalid
const getValidationError = () => {
if (isEditing) return undefined
// Only validate if we have a non-empty value
if (!value || !value.toString().trim()) return undefined
return error || validateRegex(value)
}
const currentError = getValidationError()
const getDisplayValue = (value: any, fieldType: Field<string>["fieldType"]) => {
if (fieldType.type === "select") {
return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value
@@ -107,53 +153,25 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
return value
}
// Determine the current validation state
const getValidationState = () => {
// Never show validation during editing
if (isEditing) return undefined
// Only show validation errors if there's a value and we're not editing
if (value && !isEditing) {
if (error) return error
if (validationError) return validationError
const validateAndCommit = (newValue: string | boolean) => {
// For non-string values (like checkboxes), just commit
if (typeof newValue !== 'string') {
onChange(newValue)
return true
}
// Never show required validation for empty cells
return undefined
}
const currentError = getValidationState()
useEffect(() => {
// Update validation state when value changes externally (e.g. from copy down)
if (!isEditing) {
const newValidationError = value ? validateRegex(value) : undefined
setValidationError(newValidationError)
}
}, [value])
const validateAndCommit = (newValue: string) => {
const regexError = newValue ? validateRegex(newValue) : undefined
setValidationError(regexError)
// Always commit the value
onChange(newValue)
// Only exit edit mode if there are no errors (except required field errors)
if (!error && !regexError) {
setIsEditing(false)
}
// Return whether validation passed (only validate non-empty values)
return !validateRegex(newValue)
}
// Handle blur for all input types
const handleBlur = () => {
validateAndCommit(inputValue)
setIsEditing(false)
}
// Show editing UI only when actually editing
const shouldShowEditUI = isEditing
const ValidationIcon = ({ error }: { error: { level: string, message: string } }) => (
<TooltipProvider>
<Tooltip>
@@ -169,12 +187,28 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
</TooltipProvider>
)
if (shouldShowEditUI) {
const handleWheel = (e: React.WheelEvent) => {
const commandList = e.currentTarget;
commandList.scrollTop += e.deltaY;
e.stopPropagation();
};
if (isEditing) {
switch (field.fieldType.type) {
case "select":
return (
<div className="relative">
<Popover open={isEditing} onOpenChange={setIsEditing}>
<div className="relative" id={`cell-${field.key}`}>
<Popover
open={isEditing}
onOpenChange={(open) => {
if (!open) {
validateAndCommit(value)
setIsEditing(false)
} else {
setIsEditing(true)
}
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -184,7 +218,6 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
"w-full justify-between",
currentError ? "border-destructive" : "border-input"
)}
disabled={field.disabled}
>
{value
? field.fieldType.options.find((option) => option.value === value)?.label
@@ -200,7 +233,7 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList className="max-h-[200px] overflow-y-auto">
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup>
{field.fieldType.options
@@ -212,9 +245,9 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
key={option.value}
value={option.value}
onSelect={(currentValue) => {
onChange(currentValue)
onChange(currentValue);
if (field.onChange) {
field.onChange(currentValue)
field.onChange(currentValue);
}
setSearchQuery("")
setIsEditing(false)
@@ -238,38 +271,52 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
</div>
)
case "multi-select":
const selectedValues = Array.isArray(value) ? value : value ? [value] : []
return (
<div className="relative">
<div className="relative" id={`cell-${field.key}`}>
<Popover
open={isEditing}
onOpenChange={setIsEditing}
modal={false}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isEditing}
onClick={() => setIsEditing(true)}
className={cn(
"w-full justify-between",
currentError ? "border-destructive" : "border-input"
)}
>
{selectedValues.length > 0
? `${selectedValues.length} selected`
{localValues.length > 0
? `${localValues.length} selected`
: "Select multiple..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start" side="bottom">
<Command shouldFilter={false}>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
side="bottom"
onEscapeKeyDown={() => {
setIsEditing(false)
onChange(localValues)
}}
onInteractOutside={(e) => {
const target = e.target as HTMLElement
if (!target.closest('[role="listbox"]')) {
setIsEditing(false)
onChange(localValues)
}
}}
>
<Command shouldFilter={false} className="overflow-hidden">
<CommandInput
placeholder="Search options..."
className="h-9"
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList className="max-h-[200px] overflow-y-auto">
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup>
{field.fieldType.options
@@ -280,24 +327,22 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
const valueIndex = selectedValues.indexOf(currentValue)
let newValues
if (valueIndex === -1) {
newValues = [...selectedValues, currentValue]
} else {
newValues = selectedValues.filter((_, i) => i !== valueIndex)
}
onChange(newValues)
onSelect={() => {
const valueIndex = localValues.indexOf(option.value)
const newValues = valueIndex === -1
? [...localValues, option.value]
: localValues.filter((_, i) => i !== valueIndex)
setLocalValues(newValues)
}}
onMouseDown={(e) => e.preventDefault()}
>
<div className="flex items-center gap-2">
<Checkbox
checked={selectedValues.includes(option.value)}
className="pointer-events-none"
/>
{option.label}
</div>
{option.label}
<Check
className={cn(
"ml-auto h-4 w-4",
localValues.includes(option.value) ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
@@ -308,37 +353,29 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
{currentError && <ValidationIcon error={currentError} />}
</div>
)
case "checkbox":
return (
<div className="relative flex items-center gap-2">
<Checkbox
checked={Boolean(value)}
onCheckedChange={(checked) => {
onChange(checked)
}}
/>
{currentError && <ValidationIcon error={currentError} />}
</div>
)
case "multi-input":
default:
return (
<div className="relative">
<div className="relative" id={`cell-${field.key}`}>
<Input
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
}}
onBlur={handleBlur}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleBlur()
setIsEditing(false)
if (field.fieldType.type === "multi-input") {
const separator = (field.fieldType as MultiInput).separator || ","
const values = inputValue.split(separator).map((v: string) => v.trim()).filter(Boolean)
if (validateAndCommit(values.join(separator))) {
setIsEditing(false)
}
} else if (validateAndCommit(inputValue)) {
setIsEditing(false)
}
}
}}
onBlur={() => {
handleBlur()
setIsEditing(false)
}}
className={cn(
"w-full",
currentError ? "border-destructive" : ""
@@ -357,8 +394,10 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
// Display mode
return (
<div
onClick={() => {
id={`cell-${field.key}`}
onClick={(e) => {
if (field.fieldType.type !== "checkbox" && !field.disabled) {
e.stopPropagation() // Prevent event bubbling
setIsEditing(true)
setInputValue(Array.isArray(value) ? value.join(", ") : value ?? "")
}
@@ -370,7 +409,7 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
field.disabled && "opacity-50 cursor-not-allowed bg-muted"
)}
>
<div className={cn(!value && "text-muted-foreground")}>
<div className={cn("flex-1 overflow-hidden text-ellipsis", !value && "text-muted-foreground")}>
{value ? getDisplayValue(value, field.fieldType) : ""}
</div>
{(field.fieldType.type === "select" || field.fieldType.type === "multi-select") && (
@@ -495,33 +534,43 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
const updateData = useCallback(
async (rows: typeof data, indexes?: number[]) => {
// Check if hooks are async - if they are we want to apply changes optimistically for better UX
// Set the data immediately first
setData(rows);
// Then run the hooks if they exist
if (rowHook?.constructor.name === "AsyncFunction" || tableHook?.constructor.name === "AsyncFunction") {
setData(rows)
const updatedData = await addErrorsAndRunHooks<T>(rows, fields, rowHook, tableHook, indexes);
setData(updatedData);
} else {
addErrorsAndRunHooks<T>(rows, fields, rowHook, tableHook, indexes).then(setData);
}
addErrorsAndRunHooks<T>(rows, fields, rowHook, tableHook, indexes).then((data) => setData(data))
},
[rowHook, tableHook, fields],
)
);
const updateRows = useCallback(
(rowIndex: number, columnId: string, value: string) => {
const newData = [...data]
// Get the actual row from the filtered or unfiltered data
const row = filteredData[rowIndex]
if (row) {
// Find the original index in the full dataset
const originalIndex = data.findIndex(r => r.__index === row.__index)
const updatedRow = {
...row,
[columnId]: value,
}
newData[originalIndex] = updatedRow
updateData(newData, [originalIndex])
}
(rowIndex: number, columnId: string, value: any) => {
const row = filteredData[rowIndex];
if (!row) return;
const originalIndex = data.findIndex(r => r.__index === row.__index);
if (originalIndex === -1) return;
const newData = [...data];
const updatedRow = {
...row,
[columnId]: value,
};
newData[originalIndex] = updatedRow;
// Update immediately first
setData(newData);
// Then run the async validation
updateData(newData, [originalIndex]);
},
[data, filteredData, updateData],
)
);
const copyValueDown = useCallback((key: T, label: string) => {
setCopyDownField({ key, label })