Skip to content

Commit 59bfc34

Browse files
committed
feat(node-filters): implement node filtering and pagination components
1 parent fc0286d commit 59bfc34

File tree

3 files changed

+295
-58
lines changed

3 files changed

+295
-58
lines changed
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { Input } from '@/components/ui/input'
2+
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from '@/components/ui/pagination'
3+
import useDirDetection from '@/hooks/use-dir-detection'
4+
import { cn } from '@/lib/utils'
5+
import { debounce } from 'es-toolkit'
6+
import { SearchIcon, X, RefreshCw } from 'lucide-react'
7+
import { useState, useRef, useEffect } from 'react'
8+
import { useTranslation } from 'react-i18next'
9+
import { RefetchOptions } from '@tanstack/react-query'
10+
import { LoaderCircle } from 'lucide-react'
11+
import { Button } from '@/components/ui/button'
12+
13+
interface NodeFiltersProps {
14+
filters: {
15+
search?: string
16+
limit: number
17+
offset: number
18+
}
19+
onFilterChange: (filters: Partial<NodeFiltersProps['filters']>) => void
20+
refetch?: (options?: RefetchOptions) => Promise<unknown>
21+
isFetching?: boolean
22+
}
23+
24+
export const NodeFilters = ({ filters, onFilterChange, refetch, isFetching }: NodeFiltersProps) => {
25+
const { t } = useTranslation()
26+
const dir = useDirDetection()
27+
const [search, setSearch] = useState(filters.search || '')
28+
29+
const onFilterChangeRef = useRef(onFilterChange)
30+
onFilterChangeRef.current = onFilterChange
31+
32+
const debouncedFilterChangeRef = useRef(
33+
debounce((value: string) => {
34+
onFilterChangeRef.current({
35+
search: value || undefined,
36+
offset: 0,
37+
})
38+
}, 300),
39+
)
40+
41+
useEffect(() => {
42+
return () => {
43+
debouncedFilterChangeRef.current.cancel()
44+
}
45+
}, [])
46+
47+
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
48+
const value = e.target.value
49+
setSearch(value)
50+
debouncedFilterChangeRef.current(value)
51+
}
52+
53+
const clearSearch = () => {
54+
setSearch('')
55+
debouncedFilterChangeRef.current.cancel()
56+
onFilterChange({
57+
search: undefined,
58+
offset: 0,
59+
})
60+
}
61+
62+
const handleManualRefresh = () => {
63+
if (refetch) {
64+
refetch()
65+
}
66+
}
67+
68+
return (
69+
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
70+
<div className="relative w-full md:w-[calc(100%/3-10px)]" dir={dir}>
71+
<SearchIcon className={cn('absolute', dir === 'rtl' ? 'right-2' : 'left-2', 'top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground')} />
72+
<Input
73+
placeholder={t('search')}
74+
value={search}
75+
onChange={handleSearchChange}
76+
className={cn('pl-8 pr-10', dir === 'rtl' && 'pr-8 pl-10')}
77+
/>
78+
{search && (
79+
<button
80+
onClick={clearSearch}
81+
className={cn('absolute', dir === 'rtl' ? 'left-2' : 'right-2', 'top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground')}
82+
>
83+
<X className="h-4 w-4" />
84+
</button>
85+
)}
86+
</div>
87+
88+
<div className="flex gap-2">
89+
<Button variant="outline" size="icon" onClick={handleManualRefresh} disabled={isFetching}>
90+
<RefreshCw className={cn('h-4 w-4', isFetching && 'animate-spin')} />
91+
</Button>
92+
</div>
93+
</div>
94+
)
95+
}
96+
97+
interface NodePaginationControlsProps {
98+
currentPage: number
99+
totalPages: number
100+
totalNodes: number
101+
isLoading: boolean
102+
onPageChange: (page: number) => void
103+
}
104+
105+
export const NodePaginationControls = ({ currentPage, totalPages, totalNodes, isLoading, onPageChange }: NodePaginationControlsProps) => {
106+
const { t } = useTranslation()
107+
const dir = useDirDetection()
108+
109+
const getPaginationRange = (currentPage: number, totalPages: number) => {
110+
const delta = 2
111+
const range = []
112+
113+
if (totalPages <= 5) {
114+
for (let i = 0; i < totalPages; i++) {
115+
range.push(i)
116+
}
117+
return range
118+
}
119+
120+
range.push(0)
121+
122+
let start = Math.max(1, currentPage - delta)
123+
let end = Math.min(totalPages - 2, currentPage + delta)
124+
125+
if (currentPage - delta <= 1) {
126+
end = Math.min(totalPages - 2, start + 2 * delta)
127+
}
128+
if (currentPage + delta >= totalPages - 2) {
129+
start = Math.max(1, totalPages - 3 - 2 * delta)
130+
}
131+
132+
if (start > 1) {
133+
range.push(-1)
134+
}
135+
136+
for (let i = start; i <= end; i++) {
137+
range.push(i)
138+
}
139+
140+
if (end < totalPages - 2) {
141+
range.push(-1)
142+
}
143+
144+
if (totalPages > 1) {
145+
range.push(totalPages - 1)
146+
}
147+
148+
return range
149+
}
150+
151+
const paginationRange = getPaginationRange(currentPage, totalPages)
152+
const startItem = totalNodes === 0 ? 0 : currentPage * 15 + 1
153+
const endItem = Math.min((currentPage + 1) * 15, totalNodes)
154+
155+
return (
156+
<div className="mt-4 flex flex-col-reverse items-center justify-between gap-4 md:flex-row">
157+
<div className="text-sm text-muted-foreground">
158+
{t('showing')} {startItem}-{endItem} {t('of')} {totalNodes}
159+
</div>
160+
161+
<Pagination dir="ltr" className={`${dir === 'rtl' ? 'flex-row-reverse' : ''}`}>
162+
<PaginationContent className={cn('w-full justify-center overflow-x-auto', dir === 'rtl' ? 'md:justify-start' : 'md:justify-end')}>
163+
<PaginationItem>
164+
<PaginationPrevious onClick={() => onPageChange(currentPage - 1)} disabled={currentPage === 0 || isLoading} />
165+
</PaginationItem>
166+
{paginationRange.map((pageNumber, i) =>
167+
pageNumber === -1 ? (
168+
<PaginationItem key={`ellipsis-${i}`}>
169+
<PaginationEllipsis />
170+
</PaginationItem>
171+
) : (
172+
<PaginationItem key={pageNumber}>
173+
<PaginationLink
174+
isActive={currentPage === pageNumber}
175+
onClick={() => onPageChange(pageNumber as number)}
176+
disabled={isLoading}
177+
className={isLoading && currentPage === pageNumber ? 'opacity-70' : ''}
178+
>
179+
{isLoading && currentPage === pageNumber ? (
180+
<div className="flex items-center">
181+
<LoaderCircle className="mr-1 h-3 w-3 animate-spin" />
182+
{(pageNumber as number) + 1}
183+
</div>
184+
) : (
185+
(pageNumber as number) + 1
186+
)}
187+
</PaginationLink>
188+
</PaginationItem>
189+
),
190+
)}
191+
<PaginationItem>
192+
<PaginationNext onClick={() => onPageChange(currentPage + 1)} disabled={currentPage === totalPages - 1 || totalPages === 0 || isLoading} />
193+
</PaginationItem>
194+
</PaginationContent>
195+
</Pagination>
196+
</div>
197+
)
198+
}

dashboard/src/components/nodes/nodes-list.tsx

Lines changed: 80 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { useState, useEffect, useMemo } from 'react'
1+
import { useState, useEffect, useCallback, useRef } from 'react'
22
import { useTranslation } from 'react-i18next'
33
import Node from '@/components/nodes/node'
4-
import { useGetNodes, useModifyNode, NodeResponse, NodeConnectionType } from '@/service/api'
4+
import {useGetNodes, useModifyNode, NodeResponse, NodeConnectionType} from '@/service/api'
55
import { toast } from 'sonner'
66
import { queryClient } from '@/utils/query-client'
77
import NodeModal from '@/components/dialogs/node-modal'
@@ -10,10 +10,9 @@ import { zodResolver } from '@hookform/resolvers/zod'
1010
import { nodeFormSchema, NodeFormValues } from '@/components/dialogs/node-modal'
1111
import { Card, CardContent } from '@/components/ui/card'
1212
import { Skeleton } from '@/components/ui/skeleton'
13-
import { Input } from '@/components/ui/input'
14-
import { Search, X } from 'lucide-react'
15-
import useDirDetection from '@/hooks/use-dir-detection'
16-
import { cn } from '@/lib/utils'
13+
import { NodeFilters, NodePaginationControls } from '@/components/nodes/node-filters'
14+
15+
const NODES_PER_PAGE = 15
1716

1817
const initialDefaultValues: Partial<NodeFormValues> = {
1918
name: '',
@@ -29,29 +28,76 @@ export default function NodesList() {
2928
const { t } = useTranslation()
3029
const [isDialogOpen, setIsDialogOpen] = useState(false)
3130
const [editingNode, setEditingNode] = useState<NodeResponse | null>(null)
32-
const [searchQuery, setSearchQuery] = useState('')
31+
const [currentPage, setCurrentPage] = useState(0)
32+
const [isChangingPage, setIsChangingPage] = useState(false)
33+
const isFirstLoadRef = useRef(true)
3334
const modifyNodeMutation = useModifyNode()
34-
const dir = useDirDetection()
3535

36-
const { data: nodesData, isLoading } = useGetNodes(undefined, {
37-
query: {
38-
refetchInterval: isDialogOpen && editingNode ? false : 5000,
39-
staleTime: 0,
40-
gcTime: 0,
41-
},
36+
const [filters, setFilters] = useState<{
37+
limit: number
38+
offset: number
39+
search?: string
40+
}>({
41+
limit: NODES_PER_PAGE,
42+
offset: 0,
43+
search: undefined,
4244
})
4345

4446
const form = useForm<NodeFormValues>({
4547
resolver: zodResolver(nodeFormSchema),
4648
defaultValues: initialDefaultValues,
4749
})
4850

51+
const {
52+
data: nodesResponse,
53+
isLoading,
54+
isFetching,
55+
refetch,
56+
} = useGetNodes(filters, {
57+
query: {
58+
refetchInterval: false,
59+
staleTime: 0,
60+
gcTime: 0,
61+
retry: 1,
62+
refetchOnMount: true,
63+
refetchOnWindowFocus: false,
64+
},
65+
})
66+
67+
useEffect(() => {
68+
if (nodesResponse && isFirstLoadRef.current) {
69+
isFirstLoadRef.current = false
70+
}
71+
}, [nodesResponse])
72+
4973
useEffect(() => {
5074
const handleOpenDialog = () => setIsDialogOpen(true)
5175
window.addEventListener('openNodeDialog', handleOpenDialog)
5276
return () => window.removeEventListener('openNodeDialog', handleOpenDialog)
5377
}, [])
5478

79+
const handleFilterChange = useCallback((newFilters: Partial<typeof filters>) => {
80+
setFilters(prev => ({
81+
...prev,
82+
...newFilters,
83+
}))
84+
if (newFilters.offset === 0) {
85+
setCurrentPage(0)
86+
}
87+
}, [])
88+
89+
const handlePageChange = (newPage: number) => {
90+
if (newPage === currentPage || isChangingPage) return
91+
92+
setIsChangingPage(true)
93+
setCurrentPage(newPage)
94+
setFilters(prev => ({
95+
...prev,
96+
offset: newPage * NODES_PER_PAGE,
97+
}))
98+
setIsChangingPage(false)
99+
}
100+
55101
const handleEdit = (node: NodeResponse) => {
56102
setEditingNode(node)
57103
form.reset({
@@ -105,44 +151,22 @@ export default function NodesList() {
105151
}
106152
}
107153

108-
const filteredNodes = useMemo(() => {
109-
if (!nodesData || !searchQuery.trim()) return nodesData
110-
const query = searchQuery.toLowerCase().trim()
111-
return nodesData.filter(
112-
node =>
113-
node.name?.toLowerCase().includes(query) ||
114-
node.address?.toLowerCase().includes(query) ||
115-
node.connection_type?.toLowerCase().includes(query),
116-
)
117-
}, [nodesData, searchQuery])
154+
const nodesData = nodesResponse?.nodes || []
155+
const totalNodes = nodesResponse?.total || 0
156+
const totalPages = Math.ceil(totalNodes / NODES_PER_PAGE)
157+
const showLoadingSpinner = isLoading && isFirstLoadRef.current
158+
const isPageLoading = isChangingPage
118159

119160
return (
120161
<div className="flex w-full flex-col items-start gap-2">
121162
<div className="w-full flex-1 space-y-4 pt-6">
122-
{/* Search Input */}
123-
<div className="relative w-full md:w-[calc(100%/3-10px)]" dir={dir}>
124-
<Search className={cn('absolute', dir === 'rtl' ? 'right-2' : 'left-2', 'top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground')} />
125-
<Input
126-
placeholder={t('search')}
127-
value={searchQuery}
128-
onChange={e => setSearchQuery(e.target.value)}
129-
className={cn('pl-8 pr-10', dir === 'rtl' && 'pr-8 pl-10')}
130-
/>
131-
{searchQuery && (
132-
<button
133-
onClick={() => setSearchQuery('')}
134-
className={cn('absolute', dir === 'rtl' ? 'left-2' : 'right-2', 'top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground')}
135-
>
136-
<X className="h-4 w-4" />
137-
</button>
138-
)}
139-
</div>
163+
<NodeFilters filters={filters} onFilterChange={handleFilterChange} refetch={refetch} isFetching={isFetching} />
140164

141165
<div
142166
className="mb-12 grid transform-gpu animate-slide-up grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
143167
style={{ animationDuration: '500ms', animationDelay: '100ms', animationFillMode: 'both' }}
144168
>
145-
{isLoading
169+
{showLoadingSpinner
146170
? [...Array(6)].map((_, i) => (
147171
<Card key={i} className="p-4">
148172
<div className="space-y-3">
@@ -158,10 +182,10 @@ export default function NodesList() {
158182
</div>
159183
</Card>
160184
))
161-
: filteredNodes?.map(node => <Node key={node.id} node={node} onEdit={handleEdit} onToggleStatus={handleToggleStatus} />)}
185+
: nodesData.map(node => <Node key={node.id} node={node} onEdit={handleEdit} onToggleStatus={handleToggleStatus} />)}
162186
</div>
163187

164-
{!isLoading && (!nodesData || nodesData.length === 0) && (
188+
{!showLoadingSpinner && nodesData.length === 0 && !filters.search && (
165189
<Card className="mb-12">
166190
<CardContent className="p-8 text-center">
167191
<div className="space-y-4">
@@ -178,7 +202,7 @@ export default function NodesList() {
178202
</Card>
179203
)}
180204

181-
{!isLoading && nodesData && nodesData.length > 0 && (!filteredNodes || filteredNodes.length === 0) && (
205+
{!showLoadingSpinner && nodesData.length === 0 && (filters.search) && (
182206
<Card className="mb-12">
183207
<CardContent className="p-8 text-center">
184208
<div className="space-y-4">
@@ -191,6 +215,16 @@ export default function NodesList() {
191215
</Card>
192216
)}
193217

218+
{totalNodes > NODES_PER_PAGE && (
219+
<NodePaginationControls
220+
currentPage={currentPage}
221+
totalPages={totalPages}
222+
totalNodes={totalNodes}
223+
isLoading={isPageLoading}
224+
onPageChange={handlePageChange}
225+
/>
226+
)}
227+
194228
<NodeModal
195229
isDialogOpen={isDialogOpen}
196230
onOpenChange={open => {

0 commit comments

Comments
 (0)