Skip to content

Commit 75790b2

Browse files
triplem-devImMohammad20000
authored andcommitted
feat(nodes): add node advanced search with status and core selection filter as separated component
1 parent 6038eeb commit 75790b2

File tree

8 files changed

+503
-52
lines changed

8 files changed

+503
-52
lines changed

dashboard/public/statics/locales/en.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1792,7 +1792,13 @@
17921792
"byGroup": "Group",
17931793
"selectGroup": "Select Group ... ",
17941794
"selectAdmin": "Select Admin ... ",
1795-
"selectStatus": "Select Status ... "
1795+
"selectStatus": "Select Status ... ",
1796+
"byCore": "Core Configuration",
1797+
"selectCore": "Select Core Configuration ... ",
1798+
"searchCore" : "Search Core Configurations...",
1799+
"selectedCore" : "Selected: {{name}}",
1800+
"noCoresFound": "No core configurations found",
1801+
"noCoresAvailable": "No core configurations available"
17961802
},
17971803
"clearAllFilters": "Clear All Filters",
17981804
"activeFilters": "active",

dashboard/public/statics/locales/fa.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1764,7 +1764,13 @@
17641764
"byGroup": "گروه",
17651765
"selectGroup": "انتخاب گروه ...",
17661766
"selectAdmin": "انتخاب مدیر ...",
1767-
"selectStatus": "انتخاب وضعیت ..."
1767+
"selectStatus": "انتخاب وضعیت ...",
1768+
"byCore": "هسته",
1769+
"selectCore": "انتخاب هسته ...",
1770+
"searchCore" : "جستجوی هسته ...",
1771+
"selectedCore" : "{{name}} :هسته انتخاب شده",
1772+
"noCoresFound": "هیچ هسته‌ای یافت نشد",
1773+
"noCoresAvailable": "هیچ هسته‌ای در دسترس نیست"
17681774
},
17691775
"clearAllFilters": "پاک کردن همه فیلترها",
17701776
"activeFilters": "فعال",

dashboard/public/statics/locales/ru.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1727,7 +1727,13 @@
17271727
"byGroup": "Группа",
17281728
"selectGroup": "Выберите группу ...",
17291729
"selectAdmin": "Выберите администратора ...",
1730-
"selectStatus": "Выберите статус ..."
1730+
"selectStatus": "Выберите статус ...",
1731+
"byCore": "Ядро",
1732+
"selectCore": "Выберите ядро ...",
1733+
"searchCore" : "Поиск ядер...",
1734+
"selectedCore" : "Выбрано ядер: {{name}}",
1735+
"noCoresFound": "Ядра не найдены",
1736+
"noCoresAvailable": "Нет доступных ядер"
17311737
},
17321738
"clearAllFilters": "Очистить все фильтры",
17331739
"activeFilters": "активных",

dashboard/public/statics/locales/zh.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1786,7 +1786,13 @@
17861786
"byGroup": "分组",
17871787
"selectGroup": "选择分组 ...",
17881788
"selectAdmin": "选择管理员 ...",
1789-
"selectStatus": "选择状态 ..."
1789+
"selectStatus": "选择状态 ...",
1790+
"byCore": "按核心配置",
1791+
"selectCore": "选择核心配置 ...",
1792+
"searchCore" : "搜索核心配置...",
1793+
"selectedCore" : "已选择 {{name}} 个核心配置",
1794+
"noCoresFound": "未找到核心配置",
1795+
"noCoresAvailable": "无可用核心配置"
17901796
},
17911797
"clearAllFilters": "清除所有筛选器",
17921798
"activeFilters": "活跃",
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { FormItem, FormMessage } from '@/components/ui/form'
2+
import { Input } from '@/components/ui/input'
3+
import { Skeleton } from '@/components/ui/skeleton'
4+
import { useGetAllCores } from '@/service/api'
5+
import { Search, Check } from 'lucide-react'
6+
import { useState } from 'react'
7+
import { Control, FieldPath, FieldValues, useController } from 'react-hook-form'
8+
import { useTranslation } from 'react-i18next'
9+
import { cn } from '@/lib/utils'
10+
11+
interface CoresSelectorProps<T extends FieldValues> {
12+
control: Control<T>
13+
name: FieldPath<T>
14+
onCoreChange?: (core: number | null) => void
15+
placeholder?: string
16+
}
17+
18+
export default function CoresSelector<T extends FieldValues>({ control, name, onCoreChange, placeholder }: CoresSelectorProps<T>) {
19+
const { t } = useTranslation()
20+
const [searchQuery, setSearchQuery] = useState('')
21+
22+
const { field } = useController({
23+
control,
24+
name,
25+
})
26+
27+
const { data: coresData, isLoading: coresLoading } = useGetAllCores(undefined, {
28+
query: {
29+
staleTime: 5 * 60 * 1000, // 5 minutes
30+
gcTime: 10 * 60 * 1000, // 10 minutes
31+
refetchOnWindowFocus: true,
32+
refetchOnMount: true,
33+
refetchOnReconnect: true,
34+
},
35+
})
36+
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+
)
41+
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)
50+
}
51+
}
52+
53+
const selectedCore = coresData?.cores?.find((core: any) => core.id === selectedCoreId)
54+
55+
if (coresLoading) {
56+
return (
57+
<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>
70+
</div>
71+
</FormItem>
72+
)
73+
}
74+
75+
return (
76+
<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>
87+
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" />
112+
)}
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+
)}
126+
</div>
127+
<FormMessage />
128+
</FormItem>
129+
)
130+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog.tsx'
2+
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form.tsx'
3+
import { Button } from '@/components/ui/button.tsx'
4+
import { LoaderButton } from '@/components/ui/loader-button.tsx'
5+
import useDirDetection from '@/hooks/use-dir-detection'
6+
import { UseFormReturn } from 'react-hook-form'
7+
import { useTranslation } from 'react-i18next'
8+
import { z } from 'zod'
9+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.tsx'
10+
import { Badge } from '@/components/ui/badge.tsx'
11+
import { X } from 'lucide-react'
12+
import { NodeStatus } from '@/service/api'
13+
import { Checkbox } from '@/components/ui/checkbox.tsx'
14+
import CoresSelector from '@/components/common/cores-selector.tsx'
15+
16+
interface NodeAdvanceSearchModalProps {
17+
isDialogOpen: boolean
18+
onOpenChange: (open: boolean) => void
19+
form: UseFormReturn<NodeAdvanceSearchFormValue>
20+
onSubmit: (values: NodeAdvanceSearchFormValue) => void
21+
}
22+
23+
export const nodeAdvanceSearchFormSchema = z.object({
24+
status: z.array(z.nativeEnum(NodeStatus)).optional(),
25+
core_id: z.number().nullable().optional()
26+
})
27+
28+
export type NodeAdvanceSearchFormValue = z.infer<typeof nodeAdvanceSearchFormSchema>
29+
30+
const statusOptions = [
31+
{ value: NodeStatus.connected, label: 'nodeModal.status.connected' },
32+
{ value: NodeStatus.disabled, label: 'nodeModal.status.disabled' },
33+
{ value: NodeStatus.error, label: 'nodeModal.status.error' },
34+
{ value: NodeStatus.limited, label: 'status.limited' },
35+
{ value: NodeStatus.connecting, label: 'nodeModal.status.connecting' },
36+
] as const
37+
38+
export default function NodeAdvanceSearchModal({ isDialogOpen, onOpenChange, form, onSubmit }: NodeAdvanceSearchModalProps) {
39+
const dir = useDirDetection()
40+
const { t } = useTranslation()
41+
42+
return (
43+
<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()}>
45+
<DialogHeader>
46+
<DialogTitle className={`${dir === 'rtl' ? 'text-right' : 'text-left'}`} dir={dir}>
47+
{t('advanceSearch.title')}
48+
</DialogTitle>
49+
</DialogHeader>
50+
<Form {...form}>
51+
<form onSubmit={form.handleSubmit(onSubmit)} className="flex h-full flex-col justify-between space-y-4">
52+
<div className="-mr-4 max-h-[80dvh] overflow-y-auto px-2 pr-4 sm:max-h-[75dvh]">
53+
<div className="flex w-full flex-1 flex-col items-start gap-4 pb-4">
54+
<FormField
55+
control={form.control}
56+
name="status"
57+
render={({ field }) => {
58+
return (
59+
<FormItem className="w-full flex-1">
60+
<FormLabel>{t('advanceSearch.byStatus')}</FormLabel>
61+
<FormControl>
62+
<>
63+
{/* Display selected statuses as badges */}
64+
{field.value && field.value.length > 0 && (
65+
<div className="flex flex-wrap gap-2 mb-2">
66+
{field.value.map(status => {
67+
const option = statusOptions.find(opt => opt.value === status)
68+
if (!option) return null
69+
return (
70+
<Badge key={status} variant="secondary" className="flex items-center gap-1">
71+
{t(option.label)}
72+
<X
73+
className="h-3 w-3 cursor-pointer"
74+
onClick={() => {
75+
field.onChange(field.value?.filter(s => s !== status))
76+
}}
77+
/>
78+
</Badge>
79+
)
80+
})}
81+
</div>
82+
)}
83+
84+
{/* Status selector with checkboxes */}
85+
<Select
86+
value=""
87+
onValueChange={(value: NodeStatus) => {
88+
if (!value) return
89+
const currentValue = field.value || []
90+
if (!currentValue.includes(value)) {
91+
field.onChange([...currentValue, value])
92+
}
93+
}}
94+
>
95+
<SelectTrigger dir={dir} className="w-full gap-2 py-2">
96+
<SelectValue placeholder={t('hostsDialog.selectStatus')} />
97+
</SelectTrigger>
98+
<SelectContent dir={dir} className="bg-background">
99+
{statusOptions.map(option => (
100+
<SelectItem
101+
key={option.value}
102+
value={option.value}
103+
className="flex cursor-pointer items-center gap-2 px-4 py-2 focus:bg-accent"
104+
disabled={field.value?.includes(option.value)}
105+
>
106+
<div className="flex w-full items-center gap-3">
107+
<Checkbox checked={field.value?.includes(option.value)} className="h-4 w-4" />
108+
<span className="text-sm font-normal">{t(option.label)}</span>
109+
</div>
110+
</SelectItem>
111+
))}
112+
</SelectContent>
113+
</Select>
114+
115+
{/* Clear all button */}
116+
{field.value && field.value.length > 0 && (
117+
<Button
118+
type="button"
119+
variant="outline"
120+
size="sm"
121+
onClick={() => field.onChange([])}
122+
className="mt-2 w-full"
123+
>
124+
{t('hostsDialog.clearAllStatuses')}
125+
</Button>
126+
)}
127+
</>
128+
</FormControl>
129+
<FormMessage />
130+
</FormItem>
131+
)
132+
}}
133+
/>
134+
135+
<FormField
136+
control={form.control}
137+
name="core_id"
138+
render={({ field }) => {
139+
return (
140+
<FormItem className="w-full flex-1">
141+
<FormLabel>{t('advanceSearch.byCore', { defaultValue: 'Core' })}</FormLabel>
142+
<FormControl>
143+
<CoresSelector
144+
control={form.control}
145+
name="core_id"
146+
onCoreChange={field.onChange}
147+
placeholder={t('advanceSearch.searchCore', { defaultValue: 'Search cores...' })}
148+
/>
149+
</FormControl>
150+
<FormMessage />
151+
</FormItem>
152+
)
153+
}}
154+
/>
155+
</div>
156+
</div>
157+
<div className="flex justify-end gap-2">
158+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
159+
{t('cancel')}
160+
</Button>
161+
<LoaderButton type="submit">
162+
{t('apply')}
163+
</LoaderButton>
164+
</div>
165+
</form>
166+
</Form>
167+
</DialogContent>
168+
</Dialog>
169+
)
170+
}

0 commit comments

Comments
 (0)