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>[] export type Columns<T extends string> = Column<T>[]
type ReadonlyField<T extends string> = TsDeepReadonly<Field<T>>;
export const MatchColumnsStep = <T extends string>({ export const MatchColumnsStep = <T extends string>({
data, data,

View File

@@ -56,6 +56,12 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } 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> = { type Props<T extends string> = {
initialData: (Data<T> & Meta)[] initialData: (Data<T> & Meta)[]
@@ -73,20 +79,60 @@ type CellProps = {
const EditableCell = ({ value, onChange, error, field }: CellProps) => { const EditableCell = ({ value, onChange, error, field }: CellProps) => {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [inputValue, setInputValue] = useState(value ?? "") const [inputValue, setInputValue] = useState(value ?? "")
const [validationError, setValidationError] = useState<{level: string, message: string} | undefined>(error)
const [searchQuery, setSearchQuery] = useState("") 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") const regexValidation = field.validations?.find(v => v.rule === "regex")
if (regexValidation && val) { if (regexValidation) {
const regex = new RegExp(regexValidation.value, regexValidation.flags) 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 { level: regexValidation.level || "error", message: regexValidation.errorMessage }
} }
} }
return undefined 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"]) => { const getDisplayValue = (value: any, fieldType: Field<string>["fieldType"]) => {
if (fieldType.type === "select") { if (fieldType.type === "select") {
return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value
@@ -107,53 +153,25 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
return value return value
} }
const validateAndCommit = (newValue: string | boolean) => {
// Determine the current validation state // For non-string values (like checkboxes), just commit
const getValidationState = () => { if (typeof newValue !== 'string') {
// Never show validation during editing onChange(newValue)
if (isEditing) return undefined return true
// 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
} }
// 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 // Always commit the value
onChange(newValue) onChange(newValue)
// Only exit edit mode if there are no errors (except required field errors) // Return whether validation passed (only validate non-empty values)
if (!error && !regexError) { return !validateRegex(newValue)
setIsEditing(false)
}
} }
// Handle blur for all input types
const handleBlur = () => { const handleBlur = () => {
validateAndCommit(inputValue) validateAndCommit(inputValue)
setIsEditing(false)
} }
// Show editing UI only when actually editing
const shouldShowEditUI = isEditing
const ValidationIcon = ({ error }: { error: { level: string, message: string } }) => ( const ValidationIcon = ({ error }: { error: { level: string, message: string } }) => (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
@@ -169,12 +187,28 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
</TooltipProvider> </TooltipProvider>
) )
if (shouldShowEditUI) { const handleWheel = (e: React.WheelEvent) => {
const commandList = e.currentTarget;
commandList.scrollTop += e.deltaY;
e.stopPropagation();
};
if (isEditing) {
switch (field.fieldType.type) { switch (field.fieldType.type) {
case "select": case "select":
return ( return (
<div className="relative"> <div className="relative" id={`cell-${field.key}`}>
<Popover open={isEditing} onOpenChange={setIsEditing}> <Popover
open={isEditing}
onOpenChange={(open) => {
if (!open) {
validateAndCommit(value)
setIsEditing(false)
} else {
setIsEditing(true)
}
}}
>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
@@ -184,7 +218,6 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
"w-full justify-between", "w-full justify-between",
currentError ? "border-destructive" : "border-input" currentError ? "border-destructive" : "border-input"
)} )}
disabled={field.disabled}
> >
{value {value
? field.fieldType.options.find((option) => option.value === value)?.label ? field.fieldType.options.find((option) => option.value === value)?.label
@@ -200,7 +233,7 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
value={searchQuery} value={searchQuery}
onValueChange={setSearchQuery} 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> <CommandEmpty>No options found.</CommandEmpty>
<CommandGroup> <CommandGroup>
{field.fieldType.options {field.fieldType.options
@@ -212,9 +245,9 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
key={option.value} key={option.value}
value={option.value} value={option.value}
onSelect={(currentValue) => { onSelect={(currentValue) => {
onChange(currentValue) onChange(currentValue);
if (field.onChange) { if (field.onChange) {
field.onChange(currentValue) field.onChange(currentValue);
} }
setSearchQuery("") setSearchQuery("")
setIsEditing(false) setIsEditing(false)
@@ -238,38 +271,52 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
</div> </div>
) )
case "multi-select": case "multi-select":
const selectedValues = Array.isArray(value) ? value : value ? [value] : []
return ( return (
<div className="relative"> <div className="relative" id={`cell-${field.key}`}>
<Popover <Popover
open={isEditing} open={isEditing}
onOpenChange={setIsEditing} modal={false}
> >
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={isEditing} onClick={() => setIsEditing(true)}
className={cn( className={cn(
"w-full justify-between", "w-full justify-between",
currentError ? "border-destructive" : "border-input" currentError ? "border-destructive" : "border-input"
)} )}
> >
{selectedValues.length > 0 {localValues.length > 0
? `${selectedValues.length} selected` ? `${localValues.length} selected`
: "Select multiple..."} : "Select multiple..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start" side="bottom"> <PopoverContent
<Command shouldFilter={false}> 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 <CommandInput
placeholder="Search options..." placeholder="Search options..."
className="h-9" className="h-9"
value={searchQuery} value={searchQuery}
onValueChange={setSearchQuery} 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> <CommandEmpty>No options found.</CommandEmpty>
<CommandGroup> <CommandGroup>
{field.fieldType.options {field.fieldType.options
@@ -280,24 +327,22 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
<CommandItem <CommandItem
key={option.value} key={option.value}
value={option.value} value={option.value}
onSelect={(currentValue) => { onSelect={() => {
const valueIndex = selectedValues.indexOf(currentValue) const valueIndex = localValues.indexOf(option.value)
let newValues const newValues = valueIndex === -1
if (valueIndex === -1) { ? [...localValues, option.value]
newValues = [...selectedValues, currentValue] : localValues.filter((_, i) => i !== valueIndex)
} else { setLocalValues(newValues)
newValues = selectedValues.filter((_, i) => i !== valueIndex)
}
onChange(newValues)
}} }}
onMouseDown={(e) => e.preventDefault()}
> >
<div className="flex items-center gap-2"> {option.label}
<Checkbox <Check
checked={selectedValues.includes(option.value)} className={cn(
className="pointer-events-none" "ml-auto h-4 w-4",
/> localValues.includes(option.value) ? "opacity-100" : "opacity-0"
{option.label} )}
</div> />
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
@@ -308,37 +353,29 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
{currentError && <ValidationIcon error={currentError} />} {currentError && <ValidationIcon error={currentError} />}
</div> </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": case "multi-input":
default: default:
return ( return (
<div className="relative"> <div className="relative" id={`cell-${field.key}`}>
<Input <Input
value={inputValue} value={inputValue}
onChange={(e) => { onChange={(e) => {
setInputValue(e.target.value) setInputValue(e.target.value)
}} }}
onBlur={handleBlur}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
handleBlur() if (field.fieldType.type === "multi-input") {
setIsEditing(false) 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( className={cn(
"w-full", "w-full",
currentError ? "border-destructive" : "" currentError ? "border-destructive" : ""
@@ -357,8 +394,10 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
// Display mode // Display mode
return ( return (
<div <div
onClick={() => { id={`cell-${field.key}`}
onClick={(e) => {
if (field.fieldType.type !== "checkbox" && !field.disabled) { if (field.fieldType.type !== "checkbox" && !field.disabled) {
e.stopPropagation() // Prevent event bubbling
setIsEditing(true) setIsEditing(true)
setInputValue(Array.isArray(value) ? value.join(", ") : value ?? "") 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" 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) : ""} {value ? getDisplayValue(value, field.fieldType) : ""}
</div> </div>
{(field.fieldType.type === "select" || field.fieldType.type === "multi-select") && ( {(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( const updateData = useCallback(
async (rows: typeof data, indexes?: number[]) => { 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") { 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], [rowHook, tableHook, fields],
) );
const updateRows = useCallback( const updateRows = useCallback(
(rowIndex: number, columnId: string, value: string) => { (rowIndex: number, columnId: string, value: any) => {
const newData = [...data] const row = filteredData[rowIndex];
// Get the actual row from the filtered or unfiltered data if (!row) return;
const row = filteredData[rowIndex]
if (row) { const originalIndex = data.findIndex(r => r.__index === row.__index);
// Find the original index in the full dataset if (originalIndex === -1) return;
const originalIndex = data.findIndex(r => r.__index === row.__index)
const updatedRow = { const newData = [...data];
...row, const updatedRow = {
[columnId]: value, ...row,
} [columnId]: value,
newData[originalIndex] = updatedRow };
updateData(newData, [originalIndex]) newData[originalIndex] = updatedRow;
}
// Update immediately first
setData(newData);
// Then run the async validation
updateData(newData, [originalIndex]);
}, },
[data, filteredData, updateData], [data, filteredData, updateData],
) );
const copyValueDown = useCallback((key: T, label: string) => { const copyValueDown = useCallback((key: T, label: string) => {
setCopyDownField({ key, label }) setCopyDownField({ key, label })