diff --git a/packages/ui/src/components/multi-select.tsx b/packages/ui/src/components/multi-select.tsx index 5c14e34432..77c74a73c6 100644 --- a/packages/ui/src/components/multi-select.tsx +++ b/packages/ui/src/components/multi-select.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react' +import { ReactNode, useState } from 'react' import { Button, @@ -12,6 +12,9 @@ import { SearchBox } from '@/components' import { useDebounceSearch } from '@hooks/use-debounce-search' +import { useSortedOptionsOnOpen } from '@hooks/use-sorted-options-on-open' +import { useFetchOptions } from '@hooks/useFetchOptions' +import { useFilteredOptions } from '@hooks/useFilteredOptions' import { cn } from '@utils/cn' import { TFunction } from 'i18next' @@ -32,6 +35,8 @@ export interface MultiSelectProps { customOptionElem?: (data: MultiSelectOptionType) => ReactNode error?: string label?: string + enableSortOnOpen?: boolean + fetchOptions?: (query: string) => Promise[]> } export const MultiSelect = ({ @@ -45,13 +50,29 @@ export const MultiSelect = ({ handleChangeSearchValue, customOptionElem, error, - label + label, + enableSortOnOpen = false, + fetchOptions }: MultiSelectProps) => { const { search, handleSearchChange } = useDebounceSearch({ handleChangeSearchValue, searchValue }) + const [isOpen, setIsOpen] = useState(false) + + // const filteredOptions = useFilteredOptions(options, search) + const { + options: filteredOptions, + isLoading, + error: errorFetch + } = useFetchOptions({ + searchValue: search, + fetchOptions: fetchOptions || (() => Promise.resolve([])) + }) + + const sortedOptions = useSortedOptionsOnOpen(isOpen, filteredOptions, selectedItems, enableSortOnOpen) + return ( {!!label && ( @@ -59,8 +80,8 @@ export const MultiSelect = ({ {label} )} - - + setIsOpen(open)}> + {placeholder} @@ -80,37 +101,44 @@ export const MultiSelect = ({ )} - {options.length ? ( - - {options.map(option => { - const isSelected = selectedItems.findIndex(it => it.id === option.id) > -1 + {sortedOptions.length + ? !isLoading && ( + + {sortedOptions.map(option => { + const isSelected = selectedItems.findIndex(it => it.id === option.id) > -1 - return ( - { - e.preventDefault() - handleChange(option) - }} - > -
- {isSelected && } - {customOptionElem ? ( - customOptionElem(option) - ) : ( - {option.label} - )} -
-
- ) - })} -
- ) : ( + return ( + { + e.preventDefault() + handleChange(option) + }} + > +
+ {isSelected && } + {customOptionElem ? ( + customOptionElem(option as MultiSelectOptionType) + ) : ( + {option.label} + )} +
+
+ ) + })} +
+ ) + : !isLoading && ( +
+ + {t('views:noData.noResults', 'No search results')} + +
+ )} + {isLoading && !!searchValue && (
- - {t('views:noData.noResults', 'No search results')} - + loading...
)} @@ -131,6 +159,12 @@ export const MultiSelect = ({ {error} )} + + {!!errorFetch && ( + + {errorFetch?.message} + + )}
) } diff --git a/packages/ui/src/components/search-box.tsx b/packages/ui/src/components/search-box.tsx index e06ae6bb57..e5bfab63f1 100644 --- a/packages/ui/src/components/search-box.tsx +++ b/packages/ui/src/components/search-box.tsx @@ -65,6 +65,7 @@ const Root = forwardRef( }, [onSearch]) const handleKeyDown = (e: React.KeyboardEvent) => { + e.stopPropagation() if (e.key === 'Enter') { e.preventDefault() handleSearch() diff --git a/packages/ui/src/hooks/use-sorted-options-on-open.tsx b/packages/ui/src/hooks/use-sorted-options-on-open.tsx new file mode 100644 index 0000000000..f856bc3fe6 --- /dev/null +++ b/packages/ui/src/hooks/use-sorted-options-on-open.tsx @@ -0,0 +1,25 @@ +import { useEffect, useMemo, useState } from 'react' + +export function useSortedOptionsOnOpen( + isOpen: boolean, + options: T[], + selectedItems: T[], + enable: boolean = true +): T[] { + const [snapshotIds, setSnapshotIds] = useState>(new Set()) + + useEffect(() => { + if (enable && isOpen) { + setSnapshotIds(new Set(selectedItems.map(item => item.id))) + } + }, [isOpen, enable]) + + return useMemo(() => { + if (!enable || !isOpen) return options + + const selected = options.filter(opt => snapshotIds.has(opt.id)) + const unselected = options.filter(opt => !snapshotIds.has(opt.id)) + + return [...selected, ...unselected] + }, [options, snapshotIds, isOpen, enable]) +} diff --git a/packages/ui/src/hooks/useFetchOptions.tsx b/packages/ui/src/hooks/useFetchOptions.tsx new file mode 100644 index 0000000000..b284176c1d --- /dev/null +++ b/packages/ui/src/hooks/useFetchOptions.tsx @@ -0,0 +1,62 @@ +import { useEffect, useRef, useState } from 'react' + +export interface UseFetchOptionsParams { + searchValue: string + fetchOptions: (query: string) => Promise +} + +export interface UseFetchOptionsResult { + options: T[] + isLoading: boolean + error: Error | null +} + +export function useFetchOptions({ searchValue, fetchOptions }: UseFetchOptionsParams): UseFetchOptionsResult { + const [options, setOptions] = useState([]) + const [isLoading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const abortControllerRef = useRef(null) + + useEffect(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + + const controller = new AbortController() + abortControllerRef.current = controller + + const fetchData = async () => { + setLoading(true) + setError(null) + + try { + const result = await fetchOptions(searchValue) + if (!controller.signal.aborted) { + setOptions(result) + } + } catch (err) { + if (!controller.signal.aborted) { + setError(err instanceof Error ? err : new Error('Unknown error')) + setOptions([]) + } + } finally { + if (!controller.signal.aborted) { + setLoading(false) + } + } + } + + fetchData() + + return () => { + controller.abort() + } + }, [searchValue, fetchOptions]) + + return { + options, + isLoading, + error + } +} diff --git a/packages/ui/src/hooks/useFilteredOptions.ts b/packages/ui/src/hooks/useFilteredOptions.ts new file mode 100644 index 0000000000..2be1b2402e --- /dev/null +++ b/packages/ui/src/hooks/useFilteredOptions.ts @@ -0,0 +1,16 @@ +import { useMemo } from 'react' + +export interface Option { + id: string | number + label: string +} + +export function useFilteredOptions(options: T[], searchQuery: string): T[] { + return useMemo(() => { + const search = searchQuery.trim().toLowerCase() + + if (!search) return options + + return options.filter(option => option.label.toLowerCase().includes(search)) + }, [options, searchQuery]) +} diff --git a/packages/ui/src/views/delegates/delegate-selector/delegate-selector-form.tsx b/packages/ui/src/views/delegates/delegate-selector/delegate-selector-form.tsx index 4db668dbc4..bce9d43505 100644 --- a/packages/ui/src/views/delegates/delegate-selector/delegate-selector-form.tsx +++ b/packages/ui/src/views/delegates/delegate-selector/delegate-selector-form.tsx @@ -150,6 +150,25 @@ export const DelegateSelectorForm = (props: DelegateSelectorFormProps): JSX.Elem [selectedTags, setValue] ) + const fetchOptions = useCallback( + (query: string) => { + return new Promise[]>(resolve => { + if (!query) { + resolve(tagsList.map(tag => ({ id: tag, label: tag }))) + return + } + setTimeout(() => { + resolve( + tagsList + .filter(tag => tag.toLowerCase().includes(query.toLowerCase())) + .map(tag => ({ id: tag, label: tag })) + ) + }, 500) + }) + }, + [tagsList] + ) + return ( @@ -188,6 +207,8 @@ export const DelegateSelectorForm = (props: DelegateSelectorFormProps): JSX.Elem searchValue={searchTag} handleChangeSearchValue={setSearchTag} error={errors.tags?.message?.toString()} + enableSortOnOpen + fetchOptions={fetchOptions} /> Test Delegate connectivity @@ -202,7 +223,7 @@ export const DelegateSelectorForm = (props: DelegateSelectorFormProps): JSX.Elem )} -
+