mirror of
https://github.com/chartdb/chartdb.git
synced 2025-11-02 21:13:23 +00:00
* feat: create relationships on canvas modal * feat: create relationships on canvas modal * feat: create relationships on canvas modal * fix * fix * fix * fix
490 lines
20 KiB
TypeScript
490 lines
20 KiB
TypeScript
import { CaretSortIcon, CheckIcon, Cross2Icon } from '@radix-ui/react-icons';
|
|
import * as React from 'react';
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from '@/components/command/command';
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from '@/components/popover/popover';
|
|
import { ScrollArea } from '@/components/scroll-area/scroll-area';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useEffect } from 'react';
|
|
|
|
export interface SelectBoxOption {
|
|
value: string;
|
|
label: string;
|
|
description?: string;
|
|
regex?: string;
|
|
extractRegex?: RegExp;
|
|
group?: string;
|
|
icon?: React.ReactNode;
|
|
}
|
|
|
|
export interface SelectBoxProps {
|
|
options: SelectBoxOption[];
|
|
value?: string[] | string;
|
|
valueSuffix?: string;
|
|
optionSuffix?: (option: SelectBoxOption) => string;
|
|
onChange?: (
|
|
values: string[] | string,
|
|
regexMatches?: string[] | string
|
|
) => void;
|
|
placeholder?: string;
|
|
inputPlaceholder?: string;
|
|
emptyPlaceholder?: string;
|
|
className?: string;
|
|
multiple?: boolean;
|
|
oneLine?: boolean;
|
|
selectAll?: boolean;
|
|
deselectAll?: boolean;
|
|
clearText?: string;
|
|
showClear?: boolean;
|
|
keepOrder?: boolean;
|
|
disabled?: boolean;
|
|
open?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
popoverClassName?: string;
|
|
readonly?: boolean;
|
|
footerButtons?: React.ReactNode;
|
|
commandOnMouseDown?: (e: React.MouseEvent) => void;
|
|
commandOnClick?: (e: React.MouseEvent) => void;
|
|
onSearchChange?: (search: string) => void;
|
|
}
|
|
|
|
export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
|
(
|
|
{
|
|
inputPlaceholder,
|
|
emptyPlaceholder,
|
|
placeholder,
|
|
className,
|
|
options,
|
|
value,
|
|
valueSuffix,
|
|
onChange,
|
|
multiple,
|
|
oneLine,
|
|
selectAll,
|
|
optionSuffix,
|
|
deselectAll,
|
|
clearText,
|
|
showClear,
|
|
keepOrder,
|
|
disabled,
|
|
open,
|
|
onOpenChange: setOpen,
|
|
popoverClassName,
|
|
readonly,
|
|
footerButtons,
|
|
commandOnMouseDown,
|
|
commandOnClick,
|
|
onSearchChange,
|
|
},
|
|
ref
|
|
) => {
|
|
const [searchTerm, setSearchTerm] = React.useState<string>('');
|
|
const [isOpen, setIsOpen] = React.useState(open ?? false);
|
|
const { t } = useTranslation();
|
|
|
|
useEffect(() => {
|
|
setIsOpen(open ?? false);
|
|
}, [open]);
|
|
|
|
const onOpenChange = React.useCallback(
|
|
(isOpen: boolean) => {
|
|
setOpen?.(isOpen);
|
|
setIsOpen(isOpen);
|
|
|
|
if (isOpen) {
|
|
setSearchTerm('');
|
|
}
|
|
|
|
setTimeout(() => (document.body.style.pointerEvents = ''), 500);
|
|
},
|
|
[setOpen]
|
|
);
|
|
|
|
const handleSelect = React.useCallback(
|
|
(selectedValue: string, regexMatches?: string[]) => {
|
|
if (multiple) {
|
|
const newValue =
|
|
value?.includes(selectedValue) && Array.isArray(value)
|
|
? value.filter((v) => v !== selectedValue)
|
|
: [...(value ?? []), selectedValue];
|
|
onChange?.(newValue);
|
|
} else {
|
|
onChange?.(selectedValue, regexMatches);
|
|
setIsOpen(false);
|
|
}
|
|
},
|
|
[multiple, onChange, value]
|
|
);
|
|
|
|
const handleClear = React.useCallback(() => {
|
|
if (!multiple) return;
|
|
|
|
onChange?.(multiple ? [] : '');
|
|
}, [multiple, onChange]);
|
|
|
|
const handleSelectAll = React.useCallback(() => {
|
|
if (!multiple) return;
|
|
const allIds = options.map((option) => option.value);
|
|
onChange?.(allIds);
|
|
}, [multiple, onChange, options]);
|
|
|
|
const selectedMultipleOptions = React.useMemo(
|
|
() =>
|
|
options
|
|
.filter(
|
|
(option) =>
|
|
Array.isArray(value) && value.includes(option.value)
|
|
)
|
|
.sort((a, b) => {
|
|
if (keepOrder && Array.isArray(value)) {
|
|
return (
|
|
value.indexOf(a.value) - value.indexOf(b.value)
|
|
);
|
|
}
|
|
return 0;
|
|
})
|
|
.map((option) => (
|
|
<span
|
|
key={option.value}
|
|
className={`inline-flex min-w-0 shrink-0 items-center gap-1 rounded-md border py-0.5 pl-2 pr-1 text-xs font-medium text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${oneLine ? 'mx-0.5' : ''}`}
|
|
>
|
|
<span>{option.label}</span>
|
|
{!readonly ? (
|
|
<span
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
handleSelect(option.value);
|
|
}}
|
|
className="flex items-center rounded-sm px-px text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground"
|
|
>
|
|
<Cross2Icon />
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
)),
|
|
[options, value, handleSelect, oneLine, keepOrder, readonly]
|
|
);
|
|
|
|
const isAllSelected = React.useMemo(
|
|
() =>
|
|
multiple &&
|
|
Array.isArray(value) &&
|
|
options.every((option) => value.includes(option.value)),
|
|
[options, value, multiple]
|
|
);
|
|
|
|
const handleKeyDown = React.useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if (!isOpen && e.code.toLowerCase() === 'space') {
|
|
e.preventDefault();
|
|
onOpenChange(true);
|
|
}
|
|
},
|
|
[isOpen, onOpenChange]
|
|
);
|
|
|
|
const groups = React.useMemo(
|
|
() =>
|
|
options.reduce(
|
|
(acc, option) => {
|
|
if (option.group) {
|
|
if (!acc[option.group]) {
|
|
acc[option.group] = [];
|
|
}
|
|
acc[option.group].push(option);
|
|
} else {
|
|
if (!acc['default']) {
|
|
acc['default'] = [];
|
|
}
|
|
acc['default'].push(option);
|
|
}
|
|
return acc;
|
|
},
|
|
{} as Record<string, SelectBoxOption[]>
|
|
),
|
|
[options]
|
|
);
|
|
|
|
const hasGroups = React.useMemo(
|
|
() =>
|
|
Object.keys(groups).filter((group) => group !== 'default')
|
|
.length > 0,
|
|
[groups]
|
|
);
|
|
|
|
const renderOption = React.useCallback(
|
|
(option: SelectBoxOption) => {
|
|
const isSelected =
|
|
Array.isArray(value) && value.includes(option.value);
|
|
|
|
const isRegexMatch =
|
|
option.regex && new RegExp(option.regex)?.test(searchTerm);
|
|
|
|
const matches = option.extractRegex
|
|
? searchTerm.match(option.extractRegex)
|
|
: undefined;
|
|
|
|
return (
|
|
<CommandItem
|
|
className="flex items-center"
|
|
key={option.value}
|
|
value={option.label}
|
|
keywords={option.regex ? [option.regex] : undefined}
|
|
onSelect={() =>
|
|
handleSelect(
|
|
option.value,
|
|
matches?.map((match) => match?.toString())
|
|
)
|
|
}
|
|
onMouseDown={commandOnMouseDown}
|
|
onClick={commandOnClick}
|
|
>
|
|
{multiple && (
|
|
<div
|
|
className={cn(
|
|
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
|
isSelected
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'opacity-50 [&_svg]:invisible'
|
|
)}
|
|
>
|
|
<CheckIcon />
|
|
</div>
|
|
)}
|
|
<div className="flex flex-1 items-center truncate">
|
|
{option.icon ? (
|
|
<span className="mr-2 shrink-0">
|
|
{option.icon}
|
|
</span>
|
|
) : null}
|
|
<span>
|
|
{isRegexMatch ? searchTerm : option.label}
|
|
{!isRegexMatch && optionSuffix
|
|
? optionSuffix(option)
|
|
: ''}
|
|
</span>
|
|
{option.description && (
|
|
<span className="ml-1 w-0 flex-1 truncate text-xs text-muted-foreground">
|
|
{option.description}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{((!multiple && option.value === value) ||
|
|
isRegexMatch) && (
|
|
<CheckIcon
|
|
className={cn(
|
|
'ml-auto',
|
|
option.value === value
|
|
? 'opacity-100'
|
|
: 'opacity-0'
|
|
)}
|
|
/>
|
|
)}
|
|
</CommandItem>
|
|
);
|
|
},
|
|
[
|
|
value,
|
|
multiple,
|
|
searchTerm,
|
|
handleSelect,
|
|
optionSuffix,
|
|
commandOnClick,
|
|
commandOnMouseDown,
|
|
]
|
|
);
|
|
|
|
return (
|
|
<Popover open={isOpen} onOpenChange={onOpenChange} modal={true}>
|
|
<PopoverTrigger asChild tabIndex={0} onKeyDown={handleKeyDown}>
|
|
<div
|
|
className={cn(
|
|
`flex min-h-[36px] cursor-pointer items-center justify-between rounded-md border px-3 py-1 data-[state=open]:border-ring ${disabled ? 'bg-muted pointer-events-none' : ''} ${readonly ? 'pointer-events-none' : ''}`,
|
|
className
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'items-center gap-1 overflow-hidden text-sm',
|
|
multiple
|
|
? 'flex flex-grow flex-wrap'
|
|
: 'inline-flex whitespace-nowrap'
|
|
)}
|
|
>
|
|
{value && value.length > 0 ? (
|
|
multiple ? (
|
|
oneLine ? (
|
|
<div className="block w-full min-w-0 shrink-0 truncate">
|
|
{selectedMultipleOptions}
|
|
</div>
|
|
) : (
|
|
selectedMultipleOptions
|
|
)
|
|
) : (
|
|
<div className="block w-full min-w-0 shrink-0 truncate">
|
|
{
|
|
options.find(
|
|
(opt) => opt.value === value
|
|
)?.label
|
|
}
|
|
{valueSuffix ? valueSuffix : ''}
|
|
</div>
|
|
)
|
|
) : (
|
|
<span className="mr-auto text-muted-foreground">
|
|
{placeholder}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center self-stretch pl-1 text-muted-foreground/60 hover:text-foreground [&>div]:flex [&>div]:items-center [&>div]:self-stretch">
|
|
{value &&
|
|
value.length > 0 &&
|
|
multiple &&
|
|
showClear ? (
|
|
<div
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
handleClear();
|
|
}}
|
|
>
|
|
{clearText ? (
|
|
<span className="text-xs">
|
|
{clearText}
|
|
</span>
|
|
) : (
|
|
<Cross2Icon className="size-3.5" />
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<CaretSortIcon className="size-4" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className={cn(
|
|
'w-fit min-w-[var(--radix-popover-trigger-width)] p-0',
|
|
popoverClassName
|
|
)}
|
|
align="center"
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<Command
|
|
filter={(value, search, keywords) => {
|
|
if (
|
|
keywords?.length &&
|
|
keywords.some((keyword) =>
|
|
new RegExp(keyword).test(search)
|
|
)
|
|
) {
|
|
return 1;
|
|
}
|
|
|
|
return value
|
|
.toLowerCase()
|
|
.includes(search.toLowerCase())
|
|
? 1
|
|
: 0;
|
|
}}
|
|
>
|
|
<div className="relative">
|
|
<CommandInput
|
|
value={searchTerm}
|
|
onValueChange={(e) => {
|
|
setSearchTerm(e);
|
|
onSearchChange?.(e);
|
|
}}
|
|
ref={ref}
|
|
placeholder={inputPlaceholder ?? 'Search...'}
|
|
className="h-9"
|
|
/>
|
|
{searchTerm && (
|
|
<div
|
|
className="absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3 text-muted-foreground hover:text-foreground"
|
|
onClick={() => setSearchTerm('')}
|
|
>
|
|
<Cross2Icon className="size-4" />
|
|
</div>
|
|
)}
|
|
{!searchTerm &&
|
|
multiple &&
|
|
selectAll &&
|
|
!isAllSelected && (
|
|
<div
|
|
className="absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3 text-xs text-muted-foreground hover:text-foreground"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
handleSelectAll();
|
|
}}
|
|
>
|
|
{t('select_all')}
|
|
</div>
|
|
)}
|
|
{!searchTerm &&
|
|
multiple &&
|
|
deselectAll &&
|
|
isAllSelected && (
|
|
<div
|
|
className="absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3 text-xs text-muted-foreground hover:text-foreground"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
handleClear();
|
|
}}
|
|
>
|
|
{t('deselect_all')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<CommandEmpty>
|
|
{emptyPlaceholder ?? 'No results found.'}
|
|
</CommandEmpty>
|
|
|
|
<ScrollArea>
|
|
<div className="max-h-64 w-full">
|
|
<CommandList className="max-h-fit w-full">
|
|
{hasGroups
|
|
? Object.entries(groups).map(
|
|
([groupName, groupOptions]) => (
|
|
<CommandGroup
|
|
key={groupName}
|
|
heading={groupName}
|
|
>
|
|
{groupOptions.map(
|
|
renderOption
|
|
)}
|
|
</CommandGroup>
|
|
)
|
|
)
|
|
: options.map(renderOption)}
|
|
</CommandList>
|
|
</div>
|
|
</ScrollArea>
|
|
</Command>
|
|
{footerButtons ? (
|
|
<div className="border-t">{footerButtons}</div>
|
|
) : null}
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
);
|
|
|
|
SelectBox.displayName = 'SelectBox';
|