Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 67 additions & 33 deletions packages/ui/src/components/multi-select.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ReactNode } from 'react'
import { ReactNode, useState } from 'react'

import {
Button,
Expand All @@ -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'

Expand All @@ -32,6 +35,8 @@ export interface MultiSelectProps<T = unknown> {
customOptionElem?: (data: MultiSelectOptionType<T>) => ReactNode
error?: string
label?: string
enableSortOnOpen?: boolean
fetchOptions?: (query: string) => Promise<MultiSelectOptionType<T>[]>
}

export const MultiSelect = <T = unknown,>({
Expand All @@ -45,22 +50,38 @@ export const MultiSelect = <T = unknown,>({
handleChangeSearchValue,
customOptionElem,
error,
label
label,
enableSortOnOpen = false,
fetchOptions
}: MultiSelectProps<T>) => {
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 (
<ControlGroup className={className}>
{!!label && (
<Label className="mb-2" htmlFor={''}>
{label}
</Label>
)}
<DropdownMenu.Root>
<DropdownMenu.Trigger className="data-[state=open]:border-cn-borders-8 flex h-9 w-full items-center justify-between rounded border border-cn-borders-2 bg-cn-background-2 px-3 transition-colors">
<DropdownMenu.Root onOpenChange={open => setIsOpen(open)}>
<DropdownMenu.Trigger className="data-[state=open]:border-cn-borders-8 border-cn-borders-2 bg-cn-background-2 flex h-9 w-full items-center justify-between rounded border px-3 transition-colors">
{placeholder}
<Icon name="chevron-down" className="chevron-down ml-auto" size={12} />
</DropdownMenu.Trigger>
Expand All @@ -80,37 +101,44 @@ export const MultiSelect = <T = unknown,>({
<DropdownMenu.Separator />
</>
)}
{options.length ? (
<ScrollArea viewportClassName="max-h-[300px]">
{options.map(option => {
const isSelected = selectedItems.findIndex(it => it.id === option.id) > -1
{sortedOptions.length
? !isLoading && (
<ScrollArea viewportClassName="max-h-[300px]">
{sortedOptions.map(option => {
const isSelected = selectedItems.findIndex(it => it.id === option.id) > -1

return (
<DropdownMenu.Item
key={option.id}
className={cn('px-3', { 'pl-8': !isSelected })}
onSelect={e => {
e.preventDefault()
handleChange(option)
}}
>
<div className="flex items-center gap-x-2">
{isSelected && <Icon className="min-w-3 text-icons-2" name="tick" size={12} />}
{customOptionElem ? (
customOptionElem(option)
) : (
<span className="font-medium">{option.label}</span>
)}
</div>
</DropdownMenu.Item>
)
})}
</ScrollArea>
) : (
return (
<DropdownMenu.Item
key={option.id}
className={cn('px-3', { 'pl-8': !isSelected })}
onSelect={e => {
e.preventDefault()
handleChange(option)
}}
>
<div className="flex items-center gap-x-2">
{isSelected && <Icon className="text-icons-2 min-w-3" name="tick" size={12} />}
{customOptionElem ? (
customOptionElem(option as MultiSelectOptionType<T>)
) : (
<span className="font-medium">{option.label}</span>
)}
</div>
</DropdownMenu.Item>
)
})}
</ScrollArea>
)
: !isLoading && (
<div className="px-5 py-4 text-center">
<span className="text-cn-foreground-2 leading-tight">
{t('views:noData.noResults', 'No search results')}
</span>
</div>
)}
{isLoading && !!searchValue && (
<div className="px-5 py-4 text-center">
<span className="leading-tight text-cn-foreground-2">
{t('views:noData.noResults', 'No search results')}
</span>
<span className="text-cn-foreground-2 leading-tight">loading...</span>
</div>
)}
</DropdownMenu.Content>
Expand All @@ -131,6 +159,12 @@ export const MultiSelect = <T = unknown,>({
{error}
</Message>
)}

{!!errorFetch && (
<Message className="mt-0.5" theme={MessageTheme.ERROR}>
{errorFetch?.message}
</Message>
)}
</ControlGroup>
)
}
1 change: 1 addition & 0 deletions packages/ui/src/components/search-box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const Root = forwardRef<HTMLInputElement, SearchBoxProps>(
}, [onSearch])

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
e.stopPropagation()
if (e.key === 'Enter') {
e.preventDefault()
handleSearch()
Expand Down
25 changes: 25 additions & 0 deletions packages/ui/src/hooks/use-sorted-options-on-open.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useEffect, useMemo, useState } from 'react'

export function useSortedOptionsOnOpen<T extends { id: string | number }>(
isOpen: boolean,
options: T[],
selectedItems: T[],
enable: boolean = true
): T[] {
const [snapshotIds, setSnapshotIds] = useState<Set<string | number>>(new Set())

useEffect(() => {
if (enable && isOpen) {
setSnapshotIds(new Set(selectedItems.map(item => item.id)))
}
}, [isOpen, enable])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can avoid using useEffect here — especially since selectedItems might not be updated at the right time.
You already have the onOpenChange event on DropdownMenu.Root.


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))
Comment thread
3em marked this conversation as resolved.

return [...selected, ...unselected]
}, [options, snapshotIds, isOpen, enable])
}
62 changes: 62 additions & 0 deletions packages/ui/src/hooks/useFetchOptions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useEffect, useRef, useState } from 'react'

export interface UseFetchOptionsParams<T> {
searchValue: string
fetchOptions: (query: string) => Promise<T[]>
}

export interface UseFetchOptionsResult<T> {
options: T[]
isLoading: boolean
error: Error | null
}

export function useFetchOptions<T>({ searchValue, fetchOptions }: UseFetchOptionsParams<T>): UseFetchOptionsResult<T> {
const [options, setOptions] = useState<T[]>([])
const [isLoading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)

const abortControllerRef = useRef<AbortController | null>(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
}
}
16 changes: 16 additions & 0 deletions packages/ui/src/hooks/useFilteredOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useMemo } from 'react'

export interface Option {
id: string | number
label: string
}

export function useFilteredOptions<T extends Option>(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])
}
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,25 @@ export const DelegateSelectorForm = (props: DelegateSelectorFormProps): JSX.Elem
[selectedTags, setValue]
)

const fetchOptions = useCallback(
(query: string) => {
return new Promise<MultiSelectOptionType<{ id: string; label: string }>[]>(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 (
<SandboxLayout.Content className="h-full px-0 pt-0">
<Spacer size={5} />
Expand Down Expand Up @@ -188,6 +207,8 @@ export const DelegateSelectorForm = (props: DelegateSelectorFormProps): JSX.Elem
searchValue={searchTag}
handleChangeSearchValue={setSearchTag}
error={errors.tags?.message?.toString()}
enableSortOnOpen

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, this should be the default behavior, so we can remove this prop and always apply sorting

fetchOptions={fetchOptions}
/>
</Fieldset>
<Text size={4}>Test Delegate connectivity</Text>
Expand All @@ -202,7 +223,7 @@ export const DelegateSelectorForm = (props: DelegateSelectorFormProps): JSX.Elem
</>
)}

<div className="absolute inset-x-0 bottom-0 bg-cn-background-2 p-4 shadow-md">
<div className="bg-cn-background-2 absolute inset-x-0 bottom-0 p-4 shadow-md">
<ControlGroup>
<ButtonGroup className="flex flex-row justify-between">
<Button type="button" variant="ghost" onClick={onBack}>
Expand Down