Skip to content

Commit 860e811

Browse files
committed
refactor(hooks): add useDebouncedSearch hook and use it enitre app
1 parent 1ab2689 commit 860e811

File tree

7 files changed

+94
-134
lines changed

7 files changed

+94
-134
lines changed

dashboard/src/components/admins/filters.tsx

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi
44
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
55
import useDirDetection from '@/hooks/use-dir-detection'
66
import { cn } from '@/lib/utils'
7-
import { debounce } from 'es-toolkit'
7+
import { useDebouncedSearch } from '@/hooks/use-debounced-search'
88
import { RefreshCw, SearchIcon, X } from 'lucide-react'
9-
import { useCallback, useEffect, useRef, useState } from 'react'
9+
import { useEffect } from 'react'
1010
import { useTranslation } from 'react-i18next'
1111
import { useGetAdmins } from '@/service/api'
1212
import { LoaderCircle } from 'lucide-react'
@@ -29,41 +29,25 @@ export function Filters<T extends BaseFilters>({ filters, onFilterChange }: Filt
2929
const { t } = useTranslation()
3030
const dir = useDirDetection()
3131
const { refetch } = useGetAdmins(filters)
32-
const [search, setSearch] = useState(filters.username || '')
33-
const onFilterChangeRef = useRef(onFilterChange)
34-
35-
// Keep the ref in sync with the prop
36-
onFilterChangeRef.current = onFilterChange
37-
38-
// Debounced search function
39-
const setSearchField = useCallback(
40-
debounce((value: string) => {
41-
onFilterChangeRef.current({
42-
username: value || undefined,
43-
offset: 0, // Reset to first page when search is updated
44-
} as Partial<T>)
45-
}, 300),
46-
[],
47-
)
32+
const { search, debouncedSearch, setSearch } = useDebouncedSearch(filters.username || '', 300)
4833

49-
// Cleanup on unmount
34+
// Update filters when debounced search changes
5035
useEffect(() => {
51-
return () => {
52-
setSearchField.cancel()
53-
}
54-
}, [setSearchField])
36+
onFilterChange({
37+
username: debouncedSearch || undefined,
38+
offset: 0,
39+
} as Partial<T>)
40+
}, [debouncedSearch, onFilterChange])
5541

5642
// Handle input change
5743
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
5844
setSearch(e.target.value)
59-
setSearchField(e.target.value)
6045
}
6146

6247
// Clear search field
6348
const clearSearch = () => {
6449
setSearch('')
65-
setSearchField.cancel()
66-
onFilterChangeRef.current({
50+
onFilterChange({
6751
username: undefined,
6852
offset: 0,
6953
} as Partial<T>)

dashboard/src/components/dialogs/user-online-stats-modal.tsx

Lines changed: 13 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
1+
import { useState, useEffect, useMemo, useCallback } from 'react'
2+
import { useDebouncedSearch } from '@/hooks/use-debounced-search'
23
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
34
import { Button } from '@/components/ui/button'
45
import { Input } from '@/components/ui/input'
@@ -150,11 +151,17 @@ EmptyState.displayName = 'EmptyState'
150151
export default function UserOnlineStatsModal({ isOpen, onOpenChange, nodeId, nodeName }: UserOnlineStatsDialogProps) {
151152
const { t } = useTranslation()
152153
const dir = useDirDetection()
153-
const [searchTerm, setSearchTerm] = useState('')
154154
const [specificUsername, setSpecificUsername] = useState('')
155155
const [refreshing, setRefreshing] = useState(false)
156156
const [viewingIPs, setViewingIPs] = useState<string | null>(null)
157-
const searchTimeoutRef = useRef<NodeJS.Timeout>()
157+
const { search: searchTerm, debouncedSearch: debouncedSearchTerm, setSearch: setSearchTerm } = useDebouncedSearch('', 500)
158+
159+
// Update specificUsername when debounced search changes
160+
useEffect(() => {
161+
if (debouncedSearchTerm?.trim()) {
162+
setSpecificUsername(debouncedSearchTerm.trim())
163+
}
164+
}, [debouncedSearchTerm])
158165

159166
// Reset state when modal closes
160167
useEffect(() => {
@@ -163,22 +170,8 @@ export default function UserOnlineStatsModal({ isOpen, onOpenChange, nodeId, nod
163170
setSpecificUsername('')
164171
setViewingIPs(null)
165172
setRefreshing(false)
166-
167-
// Clear any pending search timeout
168-
if (searchTimeoutRef.current) {
169-
clearTimeout(searchTimeoutRef.current)
170-
}
171173
}
172-
}, [isOpen])
173-
174-
// Cleanup timeout on unmount
175-
useEffect(() => {
176-
return () => {
177-
if (searchTimeoutRef.current) {
178-
clearTimeout(searchTimeoutRef.current)
179-
}
180-
}
181-
}, [])
174+
}, [isOpen, setSearchTerm])
182175

183176
// Memoize query options to prevent unnecessary re-renders
184177
const userStatsQueryOptions = useMemo(
@@ -285,22 +278,10 @@ export default function UserOnlineStatsModal({ isOpen, onOpenChange, nodeId, nod
285278
setSpecificUsername(searchTerm.trim())
286279
}, [searchTerm, t])
287280

288-
// Debounced search to reduce API calls
281+
// Handle search input (debounced automatically by hook)
289282
const handleSearchInput = useCallback((value: string) => {
290283
setSearchTerm(value)
291-
292-
// Clear existing timeout
293-
if (searchTimeoutRef.current) {
294-
clearTimeout(searchTimeoutRef.current)
295-
}
296-
297-
// Set new timeout for debounced search
298-
searchTimeoutRef.current = setTimeout(() => {
299-
if (value.trim()) {
300-
setSpecificUsername(value.trim())
301-
}
302-
}, 500) // 500ms delay
303-
}, [])
284+
}, [setSearchTerm])
304285

305286
const handleRefresh = useCallback(async () => {
306287
setRefreshing(true)

dashboard/src/components/nodes/node-filters.tsx

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { Input } from '@/components/ui/input'
22
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from '@/components/ui/pagination'
33
import useDirDetection from '@/hooks/use-dir-detection'
44
import { cn } from '@/lib/utils'
5-
import { debounce } from 'es-toolkit'
5+
import { useDebouncedSearch } from '@/hooks/use-debounced-search'
66
import { SearchIcon, X, RefreshCw } from 'lucide-react'
7-
import { useState, useRef, useEffect } from 'react'
7+
import { useEffect } from 'react'
88
import { useTranslation } from 'react-i18next'
99
import { RefetchOptions } from '@tanstack/react-query'
1010
import { LoaderCircle } from 'lucide-react'
@@ -24,35 +24,22 @@ interface NodeFiltersProps {
2424
export const NodeFilters = ({ filters, onFilterChange, refetch, isFetching }: NodeFiltersProps) => {
2525
const { t } = useTranslation()
2626
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-
)
27+
const { search, debouncedSearch, setSearch } = useDebouncedSearch(filters.search || '', 300)
4028

29+
// Update filters when debounced search changes
4130
useEffect(() => {
42-
return () => {
43-
debouncedFilterChangeRef.current.cancel()
44-
}
45-
}, [])
31+
onFilterChange({
32+
search: debouncedSearch || undefined,
33+
offset: 0,
34+
})
35+
}, [debouncedSearch, onFilterChange])
4636

4737
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
48-
const value = e.target.value
49-
setSearch(value)
50-
debouncedFilterChangeRef.current(value)
38+
setSearch(e.target.value)
5139
}
5240

5341
const clearSearch = () => {
5442
setSearch('')
55-
debouncedFilterChangeRef.current.cancel()
5643
onFilterChange({
5744
search: undefined,
5845
offset: 0,

dashboard/src/components/users/filters.tsx

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
77
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuLabel } from '@/components/ui/dropdown-menu'
88
import useDirDetection from '@/hooks/use-dir-detection'
99
import { cn } from '@/lib/utils'
10-
import { debounce } from 'es-toolkit'
10+
import { useDebouncedSearch } from '@/hooks/use-debounced-search'
1111
import { RefreshCw, SearchIcon, Filter, X, ArrowUpDown, User, Calendar, ChartPie, ChevronDown, Check, Clock } from 'lucide-react'
12-
import { useState, useRef, useEffect, useCallback } from 'react'
12+
import { useState, useEffect, useCallback } from 'react'
1313
import { useTranslation } from 'react-i18next'
1414
import { useGetUsers, UserStatus } from '@/service/api'
1515
import { RefetchOptions } from '@tanstack/react-query'
@@ -86,10 +86,11 @@ interface FiltersProps {
8686
export const Filters = ({ filters, onFilterChange, refetch, autoRefetch, advanceSearchOnOpen, onClearAdvanceSearch, handleSort }: FiltersProps) => {
8787
const { t } = useTranslation()
8888
const dir = useDirDetection()
89-
const [search, setSearch] = useState(filters.search || '')
9089
const [isRefreshing, setIsRefreshing] = useState(false)
9190
const [autoRefreshInterval, setAutoRefreshInterval] = useState<number>(() => getUsersAutoRefreshIntervalSeconds())
9291
const { refetch: queryRefetch, isFetching } = useGetUsers(filters)
92+
const { search, debouncedSearch, setSearch } = useDebouncedSearch(filters.search || '', 300)
93+
9394
const refetchUsers = useCallback(
9495
async (showLoading = false, isAutoRefresh = false) => {
9596
if (showLoading) {
@@ -138,39 +139,23 @@ export const Filters = ({ filters, onFilterChange, refetch, autoRefetch, advance
138139
? t('autoRefresh.shortSeconds', { count: autoRefreshInterval })
139140
: t('autoRefresh.shortMinutes', { count: Math.round(autoRefreshInterval / 60) })
140141
const currentAutoRefreshDescription = t(currentAutoRefreshOption.labelKey)
141-
const onFilterChangeRef = useRef(onFilterChange)
142142

143-
// Keep the ref in sync with the prop
144-
onFilterChangeRef.current = onFilterChange
145-
146-
// Create debounced function using es-toolkit, stored in ref to avoid recreation
147-
const debouncedFilterChangeRef = useRef(
148-
debounce((value: string) => {
149-
onFilterChangeRef.current({
150-
search: value,
151-
offset: 0,
152-
})
153-
}, 300),
154-
)
155-
156-
// Cleanup on unmount
143+
// Update filters when debounced search changes
157144
useEffect(() => {
158-
return () => {
159-
debouncedFilterChangeRef.current.cancel()
160-
}
161-
}, [])
145+
onFilterChange({
146+
search: debouncedSearch || '',
147+
offset: 0,
148+
})
149+
}, [debouncedSearch, onFilterChange])
162150

163151
// Handle input change
164152
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
165-
const value = e.target.value
166-
setSearch(value)
167-
debouncedFilterChangeRef.current(value)
153+
setSearch(e.target.value)
168154
}
169155

170156
// Clear search field
171157
const clearSearch = () => {
172158
setSearch('')
173-
debouncedFilterChangeRef.current.cancel()
174159
onFilterChange({
175160
search: '',
176161
offset: 0,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useState, useRef, useEffect, useCallback } from 'react'
2+
import { debounce } from 'es-toolkit'
3+
4+
export function useDebouncedSearch(initialValue: string = '', delay: number = 300) {
5+
const [search, setSearch] = useState(initialValue)
6+
const [debouncedSearch, setDebouncedSearch] = useState<string | undefined>(undefined)
7+
8+
const debouncedSearchRef = useRef(
9+
debounce((value: string) => {
10+
setDebouncedSearch(value || undefined)
11+
}, delay),
12+
)
13+
14+
useEffect(() => {
15+
return () => {
16+
debouncedSearchRef.current.cancel()
17+
}
18+
}, [])
19+
20+
const handleSearchChange = useCallback((value: string) => {
21+
setSearch(value)
22+
debouncedSearchRef.current(value)
23+
}, [])
24+
25+
return {
26+
search,
27+
debouncedSearch,
28+
setSearch: handleSearchChange,
29+
}
30+
}
31+

dashboard/src/pages/_dashboard._index.tsx

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import type { AdminDetails, UserResponse } from '@/service/api'
2121
import { useGetAdmins, useGetCurrentAdmin, useGetSystemStats } from '@/service/api'
2222
import { zodResolver } from '@hookform/resolvers/zod'
2323
import { useQueryClient } from '@tanstack/react-query'
24-
import { debounce } from 'es-toolkit'
24+
import { useDebouncedSearch } from '@/utils/use-debounced-search'
2525
import { Bookmark, Check, ChevronDown, Loader2, Sigma, UserCog, UserRound } from 'lucide-react'
2626
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
2727
import { useForm } from 'react-hook-form'
@@ -53,25 +53,21 @@ const Dashboard = () => {
5353

5454
// Admin search state - only for sudo admins
5555
const [selectedAdmin, setSelectedAdmin] = useState<AdminDetails | undefined>(totalAdmin)
56-
const [adminSearch, setAdminSearch] = useState('')
5756
const [offset, setOffset] = useState(0)
5857
const [admins, setAdmins] = useState<AdminDetails[]>([])
5958
const [hasMore, setHasMore] = useState(true)
6059
const [isLoading, setIsLoading] = useState(false)
6160
const [dropdownOpen, setDropdownOpen] = useState(false)
6261
const listRef = useRef<HTMLDivElement>(null)
62+
const { debouncedSearch: adminSearch, setSearch: setAdminSearchInput } = useDebouncedSearch('', 300)
6363

64-
// Debounced search - only for sudo admins
65-
const debouncedSearch = useCallback(
66-
debounce((value: string) => {
67-
if (!is_sudo) return // Don't run for non-sudo admins
68-
setOffset(0)
69-
setAdmins([])
70-
setHasMore(true)
71-
setAdminSearch(value)
72-
}, 300),
73-
[is_sudo],
74-
)
64+
// Handle debounced search side effects - only for sudo admins
65+
useEffect(() => {
66+
if (!is_sudo) return // Don't run for non-sudo admins
67+
setOffset(0)
68+
setAdmins([])
69+
setHasMore(true)
70+
}, [adminSearch, is_sudo])
7571

7672
// In the useGetAdmins call, only set username if searching and not current admin or 'system'
7773
let usernameParam: string | undefined = undefined
@@ -382,7 +378,7 @@ const Dashboard = () => {
382378
</PopoverTrigger>
383379
<PopoverContent className="w-64 p-1 sm:w-72 lg:w-80" sideOffset={4} align={dir === 'rtl' ? 'end' : 'start'}>
384380
<Command>
385-
<CommandInput placeholder={t('search')} onValueChange={debouncedSearch} className="mb-1 h-7 text-xs sm:h-8 sm:text-sm" />
381+
<CommandInput placeholder={t('search')} onValueChange={setAdminSearchInput} className="mb-1 h-7 text-xs sm:h-8 sm:text-sm" />
386382
<CommandList ref={listRef}>
387383
<CommandEmpty>
388384
<div className="py-3 text-center text-xs text-muted-foreground sm:py-4 sm:text-sm">{t('noAdminsFound') || 'No admins found'}</div>

dashboard/src/pages/_dashboard.settings.cleanup.tsx

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { formatDateByLocale } from '@/utils/datePickerUtils'
2121
import useDirDetection from '@/hooks/use-dir-detection'
2222
import { cn } from '@/lib/utils'
2323
import { useClearUsageData, useDeleteExpiredUsers, useGetAdmins, useGetCurrentAdmin, useResetUsersDataUsage, type AdminDetails, type UsageTable } from '@/service/api'
24-
import { debounce } from 'es-toolkit'
24+
import { useDebouncedSearch } from '@/hooks/use-debounced-search'
2525
import { AlertTriangle, Check, ChevronDown, Database, Loader2, RotateCcw, Server, Trash2, UserCog, UserRound } from 'lucide-react'
2626
import { useCallback, useEffect, useRef, useState } from 'react'
2727
import { useTranslation } from 'react-i18next'
@@ -44,24 +44,20 @@ export default function CleanupSettings() {
4444

4545
// Admin search state
4646
const [selectedAdmin, setSelectedAdmin] = useState<AdminDetails | undefined>()
47-
const [adminSearch, setAdminSearch] = useState('')
4847
const [offset, setOffset] = useState(0)
4948
const [admins, setAdmins] = useState<AdminDetails[]>([])
5049
const [hasMore, setHasMore] = useState(true)
5150
const [isLoading, setIsLoading] = useState(false)
5251
const [dropdownOpen, setDropdownOpen] = useState(false)
5352
const listRef = useRef<HTMLDivElement>(null)
53+
const { debouncedSearch: adminSearch, setSearch: setAdminSearchInput } = useDebouncedSearch('', 300)
5454

55-
// Debounced search
56-
const debouncedSearch = useCallback(
57-
debounce((value: string) => {
58-
setOffset(0)
59-
setAdmins([])
60-
setHasMore(true)
61-
setAdminSearch(value)
62-
}, 300),
63-
[],
64-
)
55+
// Handle debounced search side effects
56+
useEffect(() => {
57+
setOffset(0)
58+
setAdmins([])
59+
setHasMore(true)
60+
}, [adminSearch])
6561

6662
let usernameParam: string | undefined = undefined
6763
if (adminSearch && adminSearch !== 'system' && adminSearch !== currentAdmin?.username) {
@@ -294,7 +290,7 @@ export default function CleanupSettings() {
294290
</PopoverTrigger>
295291
<PopoverContent className="w-64 p-1 sm:w-72 lg:w-80" sideOffset={4} align={dir === 'rtl' ? 'end' : 'start'}>
296292
<Command>
297-
<CommandInput placeholder={t('search')} onValueChange={debouncedSearch} className="mb-1 h-7 text-xs sm:h-8 sm:text-sm" />
293+
<CommandInput placeholder={t('search')} onValueChange={setAdminSearchInput} className="mb-1 h-7 text-xs sm:h-8 sm:text-sm" />
298294
<CommandList ref={listRef}>
299295
<CommandEmpty>
300296
<div className="py-3 text-center text-xs text-muted-foreground sm:py-4 sm:text-sm">{t('noAdminsFound') || 'No admins found'}</div>

0 commit comments

Comments
 (0)