Get multi select popover to stay open
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
localValues.includes(option.value) ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
@@ -308,36 +353,28 @@ 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()
|
||||
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)
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
handleBlur()
|
||||
} else if (validateAndCommit(inputValue)) {
|
||||
setIsEditing(false)
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full",
|
||||
@@ -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)
|
||||
(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
|
||||
updateData(newData, [originalIndex])
|
||||
}
|
||||
};
|
||||
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 })
|
||||
|
||||
Reference in New Issue
Block a user