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
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const DelegateSelectorDrawer = ({ open, setOpen, preSelectedTags, onSubmit, disa
<FormSeparator className="w-full" />
<div className="flex">
Haven&apos;t installed a delegate yet?
<StyledLink className="flex flex-row items-center ml-1" variant="accent" to="#">
<StyledLink className="ml-1 flex flex-row items-center" variant="accent" to="#">
Install delegate <Icon name="attachment-link" className="ml-1" size={12} />
</StyledLink>
</div>
Expand All @@ -68,6 +68,8 @@ const DelegateSelectorDrawer = ({ open, setOpen, preSelectedTags, onSubmit, disa

<DelegateSelectorForm
delegates={delegatesData}
// TODO: Uncomment to check empty tags list when preSelected tags are defined
// tagsList={[]}
tagsList={mockTagsList}
useTranslationStore={useTranslationStore}
isLoading={false}
Expand All @@ -76,6 +78,8 @@ const DelegateSelectorDrawer = ({ open, setOpen, preSelectedTags, onSubmit, disa
isDelegateSelected={isDelegateSelected}
getMatchedDelegatesCount={getMatchedDelegatesCount}
preSelectedTags={preSelectedTags}
// TODO: Uncomment to check empty tags list when preSelected tags are defined
// preSelectedTags={['sanity-windows', 'eightfivetwoold', 'qa-automation', 'sanity']}
disableAnyDelegate={disableAnyDelegate}
/>
</Drawer.Content>
Expand Down Expand Up @@ -116,7 +120,7 @@ export const DelegateSelector = () => {
onEdit={() => setOpenA(true)}
onClear={() => setTagsA([])}
renderValue={tag => tag}
className="max-w-xs mb-8"
className="mb-8 max-w-xs"
/>

<DelegateSelectorDrawer open={openA} setOpen={setOpenA} preSelectedTags={tagsA} onSubmit={handleSubmitA} />
Expand All @@ -130,7 +134,7 @@ export const DelegateSelector = () => {
onEdit={() => setOpenB(true)}
onClear={() => setTagsB([])}
renderValue={tag => tag}
className="max-w-xs mb-8"
className="mb-8 max-w-xs"
/>

<DelegateSelectorDrawer
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/multi-select/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './multi-select'
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ReactNode } from 'react'
import { ReactNode, useEffect } from 'react'

import {
Button,
Expand All @@ -15,6 +15,8 @@ import { useDebounceSearch } from '@hooks/use-debounce-search'
import { cn } from '@utils/cn'
import { TFunction } from 'i18next'

import { useSelectedFirstOptions } from './use-selected-first-options'

export type MultiSelectOptionType<T = unknown> = T & {
id: string | number
label: string
Expand All @@ -30,6 +32,7 @@ export interface MultiSelectProps<T = unknown> {
searchValue?: string
handleChangeSearchValue?: (data: string) => void
customOptionElem?: (data: MultiSelectOptionType<T>) => ReactNode
loading?: boolean
error?: string
label?: string
}
Expand All @@ -44,23 +47,69 @@ export const MultiSelect = <T = unknown,>({
searchValue = '',
handleChangeSearchValue,
customOptionElem,
loading,
error,
label
}: MultiSelectProps<T>) => {
const { showSelectedFirstOnOpen, optionsToDisplay, setOptionsToDisplay } = useSelectedFirstOptions(
options,
selectedItems
)

const { search, handleSearchChange } = useDebounceSearch({
handleChangeSearchValue,
searchValue
})

useEffect(() => {
setOptionsToDisplay(options)
}, [search, options, setOptionsToDisplay])

const renderEmptyStateWithText = (stateText: string) => {
return (
<div className="px-5 py-4 text-center">
<span className="text-cn-foreground-2 leading-tight">{stateText}</span>
</div>
)
}

const renderOptions = () => {
return optionsToDisplay.length ? (
<ScrollArea viewportClassName="max-h-[300px]">
{optionsToDisplay.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="text-icons-2 min-w-3" name="tick" size={12} />}
{customOptionElem ? customOptionElem(option) : <span className="font-medium">{option.label}</span>}
</div>
</DropdownMenu.Item>
)
})}
</ScrollArea>
) : (
renderEmptyStateWithText(t('views:noData.noResults', 'No search results'))
)
}

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={showSelectedFirstOnOpen}>
<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,39 +129,7 @@ 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

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>
) : (
<div className="px-5 py-4 text-center">
<span className="leading-tight text-cn-foreground-2">
{t('views:noData.noResults', 'No search results')}
</span>
</div>
)}
{loading ? renderEmptyStateWithText('Loading...') : renderOptions()}
</DropdownMenu.Content>
</DropdownMenu.Root>
{!!selectedItems.length && (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Dispatch, SetStateAction, useCallback, useMemo, useState } from 'react'

import { MultiSelectOptionType } from '@/components'

type UseSelectedFirstOptionsReturnType<TOption> = {
optionsToDisplay: MultiSelectOptionType<TOption>[]
setOptionsToDisplay: Dispatch<SetStateAction<MultiSelectOptionType<TOption>[]>>
showSelectedFirstOnOpen: (open: boolean) => void
}

export const useSelectedFirstOptions = <TOption>(
options: MultiSelectOptionType<TOption>[],
selectedItems: MultiSelectOptionType<Partial<TOption>>[]
): UseSelectedFirstOptionsReturnType<TOption> => {
const [isOpen, setIsOpen] = useState(false)
const [optionsToDisplay, setOptionsToDisplay] = useState<MultiSelectOptionType<TOption>[]>(options)
const selectedIds = useMemo(() => new Set(selectedItems.map(item => item.id)), [selectedItems])

const showSelectedFirstOnOpen = useCallback(
open => {
if (open) {
const selectedFirst = [...options].sort((a, b) => {
const aSelected = selectedIds.has(a.id)
const bSelected = selectedIds.has(b.id)

if (aSelected === bSelected) return 0

return aSelected ? -1 : 1
})

setOptionsToDisplay(selectedFirst)
}

setIsOpen(open)
},
[selectedIds, options]
)

return {
optionsToDisplay: isOpen ? optionsToDisplay : options,
setOptionsToDisplay,
showSelectedFirstOnOpen
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { SandboxLayout, TranslationStore } from '@/views'
import { zodResolver } from '@hookform/resolvers/zod'
import { RadioOption, RadioSelect } from '@views/components/RadioSelect'
import { useBackendTagsSearch, useClientTagsSearch } from '@views/delegates/hooks'
import { z } from 'zod'

import { DelegateConnectivityList } from '../components/delegate-connectivity-list'
Expand Down Expand Up @@ -78,7 +79,12 @@ export const DelegateSelectorForm = (props: DelegateSelectorFormProps): JSX.Elem
disableAnyDelegate
} = props
const { t } = useTranslationStore()
const [searchTag, setSearchTag] = useState('')

// TODO: Uncomment to check the client tags search
// const { searchTag, handleSearchChange, filteredTags } = useClientTagsSearch(tagsList, preSelectedTags)

const { searchTag, handleSearchChange, filteredTags, loadingTags } = useBackendTagsSearch(tagsList, preSelectedTags)

const [matchedDelegates, setMatchedDelegates] = useState(0)
const {
register,
Expand Down Expand Up @@ -182,11 +188,12 @@ export const DelegateSelectorForm = (props: DelegateSelectorFormProps): JSX.Elem
label="Tags"
placeholder="Enter tags"
handleChange={handleTagChange}
options={tagsList.map(tag => {
options={filteredTags.map(tag => {
return { id: tag, label: tag }
})}
searchValue={searchTag}
handleChangeSearchValue={setSearchTag}
handleChangeSearchValue={handleSearchChange}
loading={loadingTags}
error={errors.tags?.message?.toString()}
/>
</Fieldset>
Expand All @@ -202,7 +209,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
2 changes: 2 additions & 0 deletions packages/ui/src/views/delegates/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './use-client-tags-search'
export * from './use-backend-tags-search'
86 changes: 86 additions & 0 deletions packages/ui/src/views/delegates/hooks/use-backend-tags-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useCallback, useEffect, useRef, useState } from 'react'

import { resolve } from 'path'

import { defaultTo, isEqual } from 'lodash-es'

const TAGS_MOCK = [
'myrunner',
'macos-arm64',
'west1-delegate-qa',
'linux-amd64',
'eightfivetwo',
'automation-eks-delegate'
]

export const useBackendTagsSearch = (tags: string[], selectedTags?: string[]) => {
const [searchTag, setSearchTag] = useState<string>('')
const [filteredTags, setFilteredTags] = useState<string[]>(() => defaultTo(selectedTags, []))
const [loadingTags, setLoadingTags] = useState<boolean>(false)
const latestQueryRef = useRef<string>('')

const fetchTagsFromBackend = useCallback(
(searchQuery: string): Promise<string[]> => {
return new Promise(resolve => {
console.log(`Fetching tags from backend... Query is ${searchQuery}`)
const tagsToResolve = tags.length === 0 ? TAGS_MOCK : tags

setTimeout(
() => {
if (searchQuery === '') {
resolve(tags)
} else {
const tagsToResolveFiltered = tagsToResolve.filter(tag =>
tag.toLowerCase().includes(searchQuery.toLowerCase())
)
resolve(tagsToResolveFiltered)
}
},
Math.floor(Math.random() * 3000)
)
})
},
[tags]
)

useEffect(() => {
const searchQuery = searchTag.trim()

if (searchQuery === latestQueryRef.current) {
return
}

latestQueryRef.current = searchQuery

setLoadingTags(true)

fetchTagsFromBackend(searchQuery)
.then(resolvedTags => {
setFilteredTags(prev => {
if (searchQuery === '' && resolvedTags.length === 0 && selectedTags) {
return selectedTags
}

if (isEqual(prev, resolvedTags)) {
return prev
}

return resolvedTags
})
})
.finally(() => {
setLoadingTags(false)
})
}, [searchTag, tags, selectedTags, fetchTagsFromBackend])

const handleSearchChange = useCallback((value: string) => {
setSearchTag(value)
}, [])

return {
searchTag,
filteredTags,
handleSearchChange,
loadingTags
}
}
28 changes: 28 additions & 0 deletions packages/ui/src/views/delegates/hooks/use-client-tags-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useMemo, useState } from 'react'

import { defaultTo } from 'lodash-es'

export const useClientTagsSearch = (tags: string[], selectedTags?: string[]) => {
const [searchTag, setSearchTag] = useState('')
const filteredTags = useMemo(() => {
if (!searchTag && tags.length === 0) {
return defaultTo(selectedTags, [])
}

if (!searchTag) return tags

const search = searchTag.toLowerCase()

return tags.filter(tag => tag.toLowerCase().includes(search))
}, [searchTag, tags, selectedTags])

const handleSearchChange = (value: string) => {
setSearchTag(value)
}

return {
searchTag,
handleSearchChange,
filteredTags
}
}