Skip to content

Commit 127a0e0

Browse files
committed
feat(cores-selector): change core selector design and fix the height of node advanced search dialog auto in all sizes
1 parent e148c4e commit 127a0e0

File tree

2 files changed

+158
-76
lines changed

2 files changed

+158
-76
lines changed

dashboard/src/components/common/cores-selector.tsx

Lines changed: 157 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import { FormItem, FormMessage } from '@/components/ui/form'
2-
import { Input } from '@/components/ui/input'
2+
import { Button } from '@/components/ui/button'
3+
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
4+
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
5+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
36
import { Skeleton } from '@/components/ui/skeleton'
47
import { useGetAllCores } from '@/service/api'
5-
import { Search, Check } from 'lucide-react'
6-
import { useState } from 'react'
8+
import { Check, ChevronDown, Loader2 } from 'lucide-react'
9+
import { useState, useEffect, useRef, useCallback } from 'react'
710
import { Control, FieldPath, FieldValues, useController } from 'react-hook-form'
811
import { useTranslation } from 'react-i18next'
12+
import { useDebouncedSearch } from '@/hooks/use-debounced-search'
13+
import useDirDetection from '@/hooks/use-dir-detection'
914
import { cn } from '@/lib/utils'
1015

16+
const PAGE_SIZE = 20
17+
1118
interface CoresSelectorProps<T extends FieldValues> {
1219
control: Control<T>
1320
name: FieldPath<T>
@@ -17,13 +24,29 @@ interface CoresSelectorProps<T extends FieldValues> {
1724

1825
export default function CoresSelector<T extends FieldValues>({ control, name, onCoreChange, placeholder }: CoresSelectorProps<T>) {
1926
const { t } = useTranslation()
20-
const [searchQuery, setSearchQuery] = useState('')
27+
const dir = useDirDetection()
2128

2229
const { field } = useController({
2330
control,
2431
name,
2532
})
2633

34+
// Pagination and search state
35+
const [offset, setOffset] = useState(0)
36+
const [cores, setCores] = useState<any[]>([])
37+
const [hasMore, setHasMore] = useState(true)
38+
const [isLoading, setIsLoading] = useState(false)
39+
const [dropdownOpen, setDropdownOpen] = useState(false)
40+
const listRef = useRef<HTMLDivElement>(null)
41+
const { debouncedSearch: coreSearch, setSearch: setCoreSearchInput } = useDebouncedSearch('', 300)
42+
43+
// Handle debounced search side effects
44+
useEffect(() => {
45+
setOffset(0)
46+
setCores([])
47+
setHasMore(true)
48+
}, [coreSearch])
49+
2750
const { data: coresData, isLoading: coresLoading } = useGetAllCores(undefined, {
2851
query: {
2952
staleTime: 5 * 60 * 1000, // 5 minutes
@@ -34,95 +57,154 @@ export default function CoresSelector<T extends FieldValues>({ control, name, on
3457
},
3558
})
3659

37-
const selectedCoreId = field.value as number | null | undefined
38-
const filteredCores = (coresData?.cores || []).filter((core: any) =>
39-
core.name.toLowerCase().includes(searchQuery.toLowerCase())
40-
)
60+
// Update cores when data is fetched
61+
useEffect(() => {
62+
if (coresData?.cores) {
63+
const allCores = coresData.cores
64+
const filteredCores = coreSearch
65+
? allCores.filter((core: any) =>
66+
core.name.toLowerCase().includes(coreSearch.toLowerCase())
67+
)
68+
: allCores
69+
70+
// Simulate pagination
71+
const paginatedCores = filteredCores.slice(0, offset + PAGE_SIZE)
72+
setCores(paginatedCores)
73+
setHasMore(paginatedCores.length < filteredCores.length)
74+
setIsLoading(false)
75+
}
76+
}, [coresData, coreSearch, offset])
4177

42-
const handleCoreSelect = (coreId: number) => {
43-
// Toggle selection: if clicking on already selected core, deselect it
44-
if (selectedCoreId === coreId) {
45-
field.onChange(null)
46-
onCoreChange?.(null)
47-
} else {
48-
field.onChange(coreId)
49-
onCoreChange?.(coreId)
78+
const handleScroll = useCallback(() => {
79+
if (!listRef.current || isLoading || !hasMore) return
80+
const { scrollTop, scrollHeight, clientHeight } = listRef.current
81+
if (scrollHeight - scrollTop - clientHeight < 100) {
82+
setIsLoading(true)
83+
setOffset(prev => prev + PAGE_SIZE)
5084
}
85+
}, [isLoading, hasMore])
86+
87+
useEffect(() => {
88+
const el = listRef.current
89+
if (!el) return
90+
el.addEventListener('scroll', handleScroll)
91+
return () => el.removeEventListener('scroll', handleScroll)
92+
}, [handleScroll])
93+
94+
const selectedCoreId = field.value as number | null | undefined
95+
96+
const handleCoreSelect = (coreId: number | null) => {
97+
field.onChange(coreId)
98+
onCoreChange?.(coreId)
99+
setDropdownOpen(false)
51100
}
52101

53102
const selectedCore = coresData?.cores?.find((core: any) => core.id === selectedCoreId)
54103

55104
if (coresLoading) {
56105
return (
57106
<FormItem>
58-
<div className="space-y-4">
59-
<div className="relative">
60-
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
61-
<Skeleton className="h-10 w-full pl-8" />
62-
</div>
63-
<div className="max-h-[200px] space-y-2 overflow-y-auto rounded-md border p-2">
64-
{Array.from({ length: 5 }).map((_, index) => (
65-
<div key={index} className="flex items-center gap-2 rounded-md p-2">
66-
<Skeleton className="h-4 w-full" />
67-
</div>
68-
))}
69-
</div>
107+
<div className="relative mb-3 w-full max-w-xs sm:mb-4 sm:max-w-sm lg:max-w-md" dir={dir}>
108+
<Skeleton className="h-8 w-full sm:h-9" />
70109
</div>
110+
<FormMessage />
71111
</FormItem>
72112
)
73113
}
74114

75115
return (
76116
<FormItem>
77-
<div className="space-y-4">
78-
<div className="relative">
79-
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
80-
<Input
81-
placeholder={placeholder || t('search', { defaultValue: 'Search' }) + ' ' + t('cores', { defaultValue: 'cores' })}
82-
value={searchQuery}
83-
onChange={e => setSearchQuery(e.target.value)}
84-
className="pl-8"
85-
/>
86-
</div>
117+
<div className="relative mb-3 w-full max-w-xs sm:mb-4 sm:max-w-sm lg:max-w-md" dir={dir}>
118+
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
119+
<PopoverTrigger asChild>
120+
<Button
121+
variant="outline"
122+
className={cn(
123+
'h-8 w-full justify-between px-2 transition-colors hover:bg-muted/50 sm:h-9 sm:px-3',
124+
'min-w-0 text-xs font-medium sm:text-sm'
125+
)}
126+
>
127+
<div className={cn(
128+
'flex min-w-0 flex-1 items-center gap-1 sm:gap-2',
129+
dir === 'rtl' ? 'flex-row-reverse' : 'flex-row'
130+
)}>
131+
<Avatar className="h-4 w-4 flex-shrink-0 sm:h-5 sm:w-5">
132+
<AvatarFallback className="bg-muted text-xs font-medium">
133+
{selectedCore?.name?.charAt(0).toUpperCase() || 'C'}
134+
</AvatarFallback>
135+
</Avatar>
136+
<span className="truncate text-xs sm:text-sm">
137+
{selectedCore?.name || placeholder || t('advanceSearch.selectCore', { defaultValue: 'Select Core' })}
138+
</span>
139+
</div>
140+
<ChevronDown className="ml-1 h-3 w-3 flex-shrink-0 text-muted-foreground" />
141+
</Button>
142+
</PopoverTrigger>
143+
<PopoverContent
144+
className="w-64 p-1 sm:w-72 lg:w-80"
145+
sideOffset={4}
146+
align={dir === 'rtl' ? 'end' : 'start'}
147+
>
148+
<Command>
149+
<CommandInput
150+
placeholder={placeholder || t('search', { defaultValue: 'Search' })}
151+
onValueChange={setCoreSearchInput}
152+
className="mb-1 h-7 text-xs sm:h-8 sm:text-sm"
153+
/>
154+
<CommandList ref={listRef}>
155+
<CommandEmpty>
156+
<div className="py-3 text-center text-xs text-muted-foreground sm:py-4 sm:text-sm">
157+
{t('advanceSearch.noCoresFound', { defaultValue: 'No cores found' })}
158+
</div>
159+
</CommandEmpty>
87160

88-
<div className="max-h-[200px] space-y-2 overflow-y-auto rounded-md border p-2">
89-
{filteredCores.length === 0 ? (
90-
<div className="flex w-full flex-col gap-2 rounded-md p-4 text-center">
91-
<span className="text-sm text-muted-foreground">
92-
{searchQuery
93-
? t('advanceSearch.noCoresFound', { defaultValue: 'No cores found' })
94-
: t('advanceSearch.noCoresAvailable', { defaultValue: 'No cores available' })
95-
}
96-
</span>
97-
</div>
98-
) : (
99-
filteredCores.map((core: any) => (
100-
<button
101-
key={core.id}
102-
type="button"
103-
onClick={() => handleCoreSelect(core.id)}
104-
className={cn(
105-
"flex w-full cursor-pointer items-center justify-between gap-2 rounded-md p-2 text-left hover:bg-accent",
106-
selectedCoreId === core.id && "bg-accent"
107-
)}
108-
>
109-
<span className="text-sm">{core.name}</span>
110-
{selectedCoreId === core.id && (
111-
<Check className="h-4 w-4 text-primary" />
161+
{/* "None" option to deselect */}
162+
<CommandItem
163+
onSelect={() => handleCoreSelect(null)}
164+
className={cn(
165+
'flex min-w-0 items-center gap-2 px-2 py-1.5 text-xs sm:text-sm',
166+
dir === 'rtl' ? 'flex-row-reverse' : 'flex-row'
167+
)}
168+
>
169+
<Avatar className="h-4 w-4 flex-shrink-0 sm:h-5 sm:w-5">
170+
<AvatarFallback className="bg-primary/10 text-xs font-medium">N</AvatarFallback>
171+
</Avatar>
172+
<span className="flex-1 truncate">{t('none', { defaultValue: 'None' })}</span>
173+
<div className="flex flex-shrink-0 items-center gap-1">
174+
{!selectedCoreId && <Check className="h-3 w-3 text-primary" />}
175+
</div>
176+
</CommandItem>
177+
178+
{cores.map((core: any) => (
179+
<CommandItem
180+
key={core.id}
181+
onSelect={() => handleCoreSelect(core.id)}
182+
className={cn(
183+
'flex min-w-0 items-center gap-2 px-2 py-1.5 text-xs sm:text-sm',
184+
dir === 'rtl' ? 'flex-row-reverse' : 'flex-row'
185+
)}
186+
>
187+
<Avatar className="h-4 w-4 flex-shrink-0 sm:h-5 sm:w-5">
188+
<AvatarFallback className="bg-muted text-xs font-medium">
189+
{core.name.charAt(0).toUpperCase()}
190+
</AvatarFallback>
191+
</Avatar>
192+
<span className="flex-1 truncate">{core.name}</span>
193+
<div className="flex flex-shrink-0 items-center gap-1">
194+
{selectedCoreId === core.id && <Check className="h-3 w-3 text-primary" />}
195+
</div>
196+
</CommandItem>
197+
))}
198+
199+
{isLoading && (
200+
<div className="flex justify-center py-2">
201+
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
202+
</div>
112203
)}
113-
</button>
114-
))
115-
)}
116-
</div>
117-
118-
{selectedCore && (
119-
<div className="text-sm text-muted-foreground">
120-
{t('advanceSearch.selectedCore', {
121-
defaultValue: 'Selected: {{name}}',
122-
name: selectedCore.name
123-
})}
124-
</div>
125-
)}
204+
</CommandList>
205+
</Command>
206+
</PopoverContent>
207+
</Popover>
126208
</div>
127209
<FormMessage />
128210
</FormItem>

dashboard/src/components/dialogs/node-advance-search-modal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export default function NodeAdvanceSearchModal({ isDialogOpen, onOpenChange, for
4141

4242
return (
4343
<Dialog open={isDialogOpen} onOpenChange={onOpenChange}>
44-
<DialogContent className="flex h-full max-w-[650px] flex-col justify-start sm:h-auto" onOpenAutoFocus={e => e.preventDefault()}>
44+
<DialogContent className="flex h-auto max-w-[650px] flex-col justify-start " onOpenAutoFocus={e => e.preventDefault()}>
4545
<DialogHeader>
4646
<DialogTitle className={`${dir === 'rtl' ? 'text-right' : 'text-left'}`} dir={dir}>
4747
{t('advanceSearch.title')}

0 commit comments

Comments
 (0)