diff --git a/apps/remix/app/components/general/webhook-multiselect-combobox.tsx b/apps/remix/app/components/general/webhook-multiselect-combobox.tsx index d9f7bea62..fb2219c2a 100644 --- a/apps/remix/app/components/general/webhook-multiselect-combobox.tsx +++ b/apps/remix/app/components/general/webhook-multiselect-combobox.tsx @@ -1,96 +1,43 @@ -import { useEffect, useState } from 'react'; - -import { Plural, Trans } from '@lingui/react/macro'; import { WebhookTriggerEvents } from '@prisma/client'; -import { Check, ChevronsUpDown } from 'lucide-react'; import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name'; -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, -} from '@documenso/ui/primitives/command'; -import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; - -import { truncateTitle } from '~/utils/truncate-title'; +import { MultipleSelector } from '@documenso/ui/primitives/multiselect'; type WebhookMultiSelectComboboxProps = { listValues: string[]; onChange: (_values: string[]) => void; }; +const triggerEvents = Object.values(WebhookTriggerEvents).map((value) => ({ + value, + label: toFriendlyWebhookEventName(value), +})); + export const WebhookMultiSelectCombobox = ({ listValues, onChange, }: WebhookMultiSelectComboboxProps) => { - const [isOpen, setIsOpen] = useState(false); - const [selectedValues, setSelectedValues] = useState([]); - - const triggerEvents = Object.values(WebhookTriggerEvents); - - useEffect(() => { - setSelectedValues(listValues); - }, [listValues]); - - const allEvents = [...new Set([...triggerEvents, ...selectedValues])]; - - const handleSelect = (currentValue: string) => { - let newSelectedValues; - - if (selectedValues.includes(currentValue)) { - newSelectedValues = selectedValues.filter((value) => value !== currentValue); - } else { - newSelectedValues = [...selectedValues, currentValue]; - } - - setSelectedValues(newSelectedValues); - onChange(newSelectedValues); - setIsOpen(false); + const handleOnChange = (options: { value: string; label: string }[]) => { + onChange(options.map((option) => option.value)); }; + const mappedValues = listValues.map((value) => ({ + value, + label: toFriendlyWebhookEventName(value), + })); + return ( - - - - - - - toFriendlyWebhookEventName(v)).join(', '), - 15, - )} - /> - - No value found. - - - {allEvents.map((value: string, i: number) => ( - handleSelect(value)}> - - {toFriendlyWebhookEventName(value)} - - ))} - - - - + No triggers available

} + /> ); }; diff --git a/packages/ui/primitives/multiselect.tsx b/packages/ui/primitives/multiselect.tsx new file mode 100644 index 000000000..03ae7c7cb --- /dev/null +++ b/packages/ui/primitives/multiselect.tsx @@ -0,0 +1,587 @@ +import * as React from 'react'; +import { forwardRef, useEffect } from 'react'; + +import { Command as CommandPrimitive, useCommandState } from 'cmdk'; +import { X } from 'lucide-react'; + +import { cn } from '../lib/utils'; +import { Command, CommandGroup, CommandItem, CommandList } from './command'; + +export interface Option { + value: string; + label: string; + disable?: boolean; + fixed?: boolean; + [key: string]: string | boolean | undefined; +} +interface GroupOption { + [key: string]: Option[]; +} + +interface MultipleSelectorProps { + value?: Option[]; + defaultOptions?: Option[]; + options?: Option[]; + placeholder?: string; + loadingIndicator?: React.ReactNode; + emptyIndicator?: React.ReactNode; + delay?: number; + triggerSearchOnFocus?: boolean; + onSearch?: (value: string) => Promise; + onSearchSync?: (value: string) => Option[]; + onChange?: (options: Option[]) => void; + maxSelected?: number; + onMaxSelected?: (maxLimit: number) => void; + hidePlaceholderWhenSelected?: boolean; + disabled?: boolean; + groupBy?: string; + className?: string; + badgeClassName?: string; + selectFirstItem?: boolean; + creatable?: boolean; + commandProps?: React.ComponentPropsWithoutRef; + inputProps?: Omit< + React.ComponentPropsWithoutRef, + 'value' | 'placeholder' | 'disabled' + >; + hideClearAllButton?: boolean; +} + +export interface MultipleSelectorRef { + selectedValue: Option[]; + input: HTMLInputElement; + focus: () => void; + reset: () => void; +} + +export function useDebounce(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = React.useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay || 500); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} + +function transToGroupOption(options: Option[], groupBy?: string) { + if (options.length === 0) { + return {}; + } + if (!groupBy) { + return { + '': options, + }; + } + + const groupOption: GroupOption = {}; + options.forEach((option) => { + const key = (option[groupBy] as string) || ''; + if (!groupOption[key]) { + groupOption[key] = []; + } + groupOption[key].push(option); + }); + return groupOption; +} + +function removePickedOption(groupOption: GroupOption, picked: Option[]) { + const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption; + + for (const [key, value] of Object.entries(cloneOption)) { + cloneOption[key] = value.filter((val) => !picked.find((p) => p.value === val.value)); + } + return cloneOption; +} + +function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) { + for (const [, value] of Object.entries(groupOption)) { + if (value.some((option) => targetOption.find((p) => p.value === option.value))) { + return true; + } + } + return false; +} + +/** + * The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly. + * So we create one and copy the `Empty` implementation from `cmdk`. + * + * @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607 + **/ +const CommandEmpty = forwardRef< + HTMLDivElement, + React.ComponentProps +>(({ className, ...props }, forwardedRef) => { + const render = useCommandState((state) => state.filtered.count === 0); + + if (!render) return null; + + return ( +
+ ); +}); + +CommandEmpty.displayName = 'CommandEmpty'; + +const MultipleSelector = React.forwardRef( + ( + { + value, + onChange, + placeholder, + defaultOptions: arrayDefaultOptions = [], + options: arrayOptions, + delay, + onSearch, + onSearchSync, + loadingIndicator, + emptyIndicator, + maxSelected = Number.MAX_SAFE_INTEGER, + onMaxSelected, + hidePlaceholderWhenSelected, + disabled, + groupBy, + className, + badgeClassName, + selectFirstItem = true, + creatable = false, + triggerSearchOnFocus = false, + commandProps, + inputProps, + hideClearAllButton = false, + }: MultipleSelectorProps, + ref: React.Ref, + ) => { + const inputRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [onScrollbar, setOnScrollbar] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + const dropdownRef = React.useRef(null); // Added this + + const [selected, setSelected] = React.useState(value || []); + const [options, setOptions] = React.useState( + transToGroupOption(arrayDefaultOptions, groupBy), + ); + const [inputValue, setInputValue] = React.useState(''); + const debouncedSearchTerm = useDebounce(inputValue, delay || 500); + + React.useImperativeHandle( + ref, + () => ({ + selectedValue: [...selected], + input: inputRef.current as HTMLInputElement, + focus: () => inputRef?.current?.focus(), + reset: () => setSelected([]), + }), + [selected], + ); + + const handleClickOutside = (event: MouseEvent | TouchEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setOpen(false); + inputRef.current.blur(); + } + }; + + const handleUnselect = React.useCallback( + (option: Option) => { + const newOptions = selected.filter((s) => s.value !== option.value); + setSelected(newOptions); + onChange?.(newOptions); + }, + [onChange, selected], + ); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + const input = inputRef.current; + if (input) { + if (e.key === 'Delete' || e.key === 'Backspace') { + if (input.value === '' && selected.length > 0) { + const lastSelectOption = selected[selected.length - 1]; + // If last item is fixed, we should not remove it. + if (!lastSelectOption.fixed) { + handleUnselect(selected[selected.length - 1]); + } + } + } + // This is not a default behavior of the field + if (e.key === 'Escape') { + input.blur(); + } + } + }, + [handleUnselect, selected], + ); + + useEffect(() => { + if (open) { + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('touchend', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('touchend', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('touchend', handleClickOutside); + }; + }, [open]); + + useEffect(() => { + if (value) { + setSelected(value); + } + }, [value]); + + useEffect(() => { + /** If `onSearch` is provided, do not trigger options updated. */ + if (!arrayOptions || onSearch) { + return; + } + const newOption = transToGroupOption(arrayOptions || [], groupBy); + if (JSON.stringify(newOption) !== JSON.stringify(options)) { + setOptions(newOption); + } + }, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]); + + useEffect(() => { + /** sync search */ + + const doSearchSync = () => { + const res = onSearchSync?.(debouncedSearchTerm); + setOptions(transToGroupOption(res || [], groupBy)); + }; + + const exec = () => { + if (!onSearchSync || !open) return; + + if (triggerSearchOnFocus) { + doSearchSync(); + } + + if (debouncedSearchTerm) { + doSearchSync(); + } + }; + + void exec(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]); + + useEffect(() => { + /** async search */ + + const doSearch = async () => { + setIsLoading(true); + const res = await onSearch?.(debouncedSearchTerm); + setOptions(transToGroupOption(res || [], groupBy)); + setIsLoading(false); + }; + + const exec = async () => { + if (!onSearch || !open) return; + + if (triggerSearchOnFocus) { + await doSearch(); + } + + if (debouncedSearchTerm) { + await doSearch(); + } + }; + + void exec(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]); + + const CreatableItem = () => { + if (!creatable) return undefined; + if ( + isOptionsExist(options, [{ value: inputValue, label: inputValue }]) || + selected.find((s) => s.value === inputValue) + ) { + return undefined; + } + + const Item = ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={(value: string) => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length); + return; + } + setInputValue(''); + const newOptions = [...selected, { value, label: value }]; + setSelected(newOptions); + onChange?.(newOptions); + }} + > + {`Create "${inputValue}"`} + + ); + + // For normal creatable + if (!onSearch && inputValue.length > 0) { + return Item; + } + + // For async search creatable. avoid showing creatable item before loading at first. + if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) { + return Item; + } + + return undefined; + }; + + const EmptyItem = React.useCallback(() => { + if (!emptyIndicator) return undefined; + + // For async search that showing emptyIndicator + if (onSearch && !creatable && Object.keys(options).length === 0) { + return ( + + {emptyIndicator} + + ); + } + + return {emptyIndicator}; + }, [creatable, emptyIndicator, onSearch, options]); + + const selectables = React.useMemo( + () => removePickedOption(options, selected), + [options, selected], + ); + + /** Avoid Creatable Selector freezing or lagging when paste a long string. */ + const commandFilter = React.useCallback(() => { + if (commandProps?.filter) { + return commandProps.filter; + } + + if (creatable) { + return (value: string, search: string) => { + return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1; + }; + } + // Using default filter in `cmdk`. We don‘t have to provide it. + return undefined; + }, [creatable, commandProps?.filter]); + + return ( + { + handleKeyDown(e); + commandProps?.onKeyDown?.(e); + }} + className={cn('h-auto overflow-visible bg-transparent', commandProps?.className)} + shouldFilter={ + commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch + } // When onSearch is provided, we don‘t want to filter the options. You can still override it. + filter={commandFilter()} + > +
{ + if (disabled) return; + inputRef?.current?.focus(); + }} + > +
+ {selected.map((option) => { + return ( +
+ {option.label} + +
+ ); + })} + {/* Avoid having the "Search" Icon */} + { + setInputValue(value); + inputProps?.onValueChange?.(value); + }} + onBlur={(event) => { + if (!onScrollbar) { + setOpen(false); + } + inputProps?.onBlur?.(event); + }} + onFocus={(event) => { + setOpen(true); + if (triggerSearchOnFocus) { + void onSearch?.(debouncedSearchTerm); + } + inputProps?.onFocus?.(event); + }} + placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? '' : placeholder} + className={cn( + 'placeholder:text-muted-foreground flex-1 bg-transparent outline-none disabled:cursor-not-allowed', + { + 'w-full': hidePlaceholderWhenSelected, + 'px-3 py-2': selected.length === 0, + 'ml-1': selected.length !== 0, + }, + inputProps?.className, + )} + /> + +
+
+
+
+ {open && ( + { + setOnScrollbar(false); + }} + onMouseEnter={() => { + setOnScrollbar(true); + }} + onMouseUp={() => { + inputRef?.current?.focus(); + }} + > + {isLoading ? ( + <>{loadingIndicator} + ) : ( + <> + {EmptyItem()} + {CreatableItem()} + {!selectFirstItem && } + {Object.entries(selectables).map(([key, dropdowns]) => ( + + <> + {dropdowns.map((option) => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={() => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length); + return; + } + setInputValue(''); + const newOptions = [...selected, option]; + setSelected(newOptions); + onChange?.(newOptions); + }} + className={cn( + 'cursor-pointer', + option.disable && 'cursor-not-allowed opacity-50', + )} + > + {option.label} + + ); + })} + + + ))} + + )} + + )} +
+
+
+ ); + }, +); + +MultipleSelector.displayName = 'MultipleSelector'; + +export { MultipleSelector };