diff --git a/web/src/features/keys/components/api-keys-table.tsx b/web/src/features/keys/components/api-keys-table.tsx index 3561df52..2ee49792 100644 --- a/web/src/features/keys/components/api-keys-table.tsx +++ b/web/src/features/keys/components/api-keys-table.tsx @@ -307,6 +307,7 @@ export function ApiKeysTable() { applyHeaderSize toolbarProps={{ searchPlaceholder: t('Filter by name...'), + searchDebounceMs: 500, additionalSearch: ( . For commercial licensing, please contact support@quantumnous.com */ import { useQueryClient } from '@tanstack/react-query' -import { type ColumnDef, type RowSelectionState } from '@tanstack/react-table' +import type { ColumnDef, RowSelectionState } from '@tanstack/react-table' import { Search, Info, @@ -48,6 +48,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { useDebounce } from '@/hooks/use-debounce' import { useIsMobile } from '@/hooks/use-mobile' import { applyUpstreamOverwrite } from '../../api' @@ -117,6 +118,7 @@ export function UpstreamConflictDialog({ } = useModels() const isMobile = useIsMobile() const [search, setSearch] = useState('') + const debouncedSearch = useDebounce(search, 200) const [isSubmitting, setIsSubmitting] = useState(false) const [rowSelection, setRowSelection] = useState({}) const [pageSize, setPageSize] = useState(10) @@ -149,7 +151,7 @@ export function UpstreamConflictDialog({ const totalModels = upstreamConflicts.length const totalFields = conflictRows.length - const normalizedSearch = search.trim().toLowerCase() + const normalizedSearch = debouncedSearch.trim().toLowerCase() const { matchingModelNames, visibleRowIds } = useMemo(() => { if (!normalizedSearch) { @@ -396,7 +398,7 @@ export function UpstreamConflictDialog({ const payload: SyncOverwritePayload[] = Object.entries(groupedSelections) .map(([modelName, fields]) => ({ model_name: modelName, - fields: Array.from(fields), + fields: [...fields], })) .filter((item) => item.fields.length > 0) diff --git a/web/src/features/models/components/models-table.tsx b/web/src/features/models/components/models-table.tsx index 91640ddb..4a9b7b6e 100644 --- a/web/src/features/models/components/models-table.tsx +++ b/web/src/features/models/components/models-table.tsx @@ -202,6 +202,7 @@ export function ModelsTable() { applyHeaderSize toolbarProps={{ searchPlaceholder: t('Filter by model name...'), + searchDebounceMs: 500, filters: [ { columnId: 'status', diff --git a/web/src/features/pricing/hooks/use-filters.ts b/web/src/features/pricing/hooks/use-filters.ts index 18415b8a..c055153f 100644 --- a/web/src/features/pricing/hooks/use-filters.ts +++ b/web/src/features/pricing/hooks/use-filters.ts @@ -19,6 +19,8 @@ For commercial licensing, please contact support@quantumnous.com import { useSearch } from '@tanstack/react-router' import { useMemo, useCallback, useState } from 'react' +import { useDebounce } from '@/hooks/use-debounce' + import { FILTER_ALL, SORT_OPTIONS, @@ -67,6 +69,7 @@ export function useFilters(models: PricingModel[]) { })) const searchInput = filterState.search || '' + const debouncedSearchInput = useDebounce(searchInput, 200) const sortBy = filterState.sort || SORT_OPTIONS.NAME const vendorFilter = filterState.vendor || FILTER_ALL const groupFilter = filterState.group || FILTER_ALL @@ -147,7 +150,7 @@ export function useFilters(models: PricingModel[]) { if (!models || models.length === 0) return [] return filterAndSortModels(models, { - search: searchInput, + search: debouncedSearchInput, vendor: vendorFilter, group: groupFilter, quotaType: quotaTypeFilter, @@ -157,7 +160,7 @@ export function useFilters(models: PricingModel[]) { }) }, [ models, - searchInput, + debouncedSearchInput, vendorFilter, groupFilter, quotaTypeFilter, diff --git a/web/src/features/redemption-codes/components/redemptions-table.tsx b/web/src/features/redemption-codes/components/redemptions-table.tsx index 391d64cf..850433ee 100644 --- a/web/src/features/redemption-codes/components/redemptions-table.tsx +++ b/web/src/features/redemption-codes/components/redemptions-table.tsx @@ -171,6 +171,7 @@ export function RedemptionsTable() { applyHeaderSize toolbarProps={{ searchPlaceholder: t('Filter by name or ID...'), + searchDebounceMs: 500, filters: [ { columnId: 'status', diff --git a/web/src/features/system-settings/models/channel-selector-dialog.tsx b/web/src/features/system-settings/models/channel-selector-dialog.tsx index e8b4d233..0be3348a 100644 --- a/web/src/features/system-settings/models/channel-selector-dialog.tsx +++ b/web/src/features/system-settings/models/channel-selector-dialog.tsx @@ -39,6 +39,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { useDebounce } from '@/hooks/use-debounce' import type { UpstreamChannel } from '../types' import { @@ -80,6 +81,7 @@ export function ChannelSelectorDialog({ }: ChannelSelectorDialogProps) { const { t } = useTranslation() const [search, setSearch] = useState('') + const debouncedSearch = useDebounce(search, 200) const [rowSelection, setRowSelection] = useState({}) useEffect(() => { @@ -273,15 +275,15 @@ export function ChannelSelectorDialog({ ) const filteredChannels = useMemo(() => { - if (!search.trim()) return channels + if (!debouncedSearch.trim()) return channels - const searchLower = search.toLowerCase() + const searchLower = debouncedSearch.toLowerCase() return channels.filter( (ch) => ch.name.toLowerCase().includes(searchLower) || ch.base_url.toLowerCase().includes(searchLower) ) - }, [channels, search]) + }, [channels, debouncedSearch]) const sortedChannels = useMemo(() => { return [...filteredChannels].sort((a, b) => { diff --git a/web/src/features/system-settings/models/model-ratio-visual-editor.tsx b/web/src/features/system-settings/models/model-ratio-visual-editor.tsx index f50093cf..5013455c 100644 --- a/web/src/features/system-settings/models/model-ratio-visual-editor.tsx +++ b/web/src/features/system-settings/models/model-ratio-visual-editor.tsx @@ -682,6 +682,7 @@ const ModelRatioVisualEditorComponent = forwardRef< ('') const dataSource = useMemo(() => { @@ -106,8 +108,8 @@ export function UpstreamRatioSyncTable({ const filteredData = useMemo(() => { let data = dataSource - if (search.trim()) { - const lower = search.toLowerCase() + if (debouncedSearch.trim()) { + const lower = debouncedSearch.toLowerCase() data = data.filter((row) => row.model.toLowerCase().includes(lower)) } @@ -116,7 +118,7 @@ export function UpstreamRatioSyncTable({ } return data - }, [dataSource, search, ratioTypeFilter]) + }, [dataSource, debouncedSearch, ratioTypeFilter]) const upstreamNames = useMemo(() => { const set = new Set() diff --git a/web/src/features/wallet/hooks/use-billing-history.ts b/web/src/features/wallet/hooks/use-billing-history.ts index 48aead6a..54f98d5f 100644 --- a/web/src/features/wallet/hooks/use-billing-history.ts +++ b/web/src/features/wallet/hooks/use-billing-history.ts @@ -17,10 +17,11 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import i18next from 'i18next' -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { toast } from 'sonner' import { useIsAdmin } from '@/hooks/use-admin' +import { useDebounce } from '@/hooks/use-debounce' import { getUserBillingHistory, @@ -50,6 +51,8 @@ export function useBillingHistory(options: UseBillingHistoryOptions = {}) { const [page, setPage] = useState(initialPage) const [pageSize, setPageSize] = useState(initialPageSize) const [keyword, setKeyword] = useState('') + const debouncedKeyword = useDebounce(keyword) + const requestIdRef = useRef(0) const [loading, setLoading] = useState(false) const [completing, setCompleting] = useState(false) @@ -57,11 +60,14 @@ export function useBillingHistory(options: UseBillingHistoryOptions = {}) { * Fetch billing history */ const fetchBillingHistory = useCallback(async () => { + const requestId = ++requestIdRef.current setLoading(true) try { const response = isAdmin - ? await getAllBillingHistory(page, pageSize, keyword) - : await getUserBillingHistory(page, pageSize, keyword) + ? await getAllBillingHistory(page, pageSize, debouncedKeyword) + : await getUserBillingHistory(page, pageSize, debouncedKeyword) + + if (requestId !== requestIdRef.current) return if (isApiSuccess(response) && response.data) { setRecords(response.data.items || []) @@ -74,15 +80,19 @@ export function useBillingHistory(options: UseBillingHistoryOptions = {}) { setTotal(0) } } catch (error) { + if (requestId !== requestIdRef.current) return + // eslint-disable-next-line no-console console.error('Failed to fetch billing history:', error) toast.error(i18next.t('Failed to load billing history')) setRecords([]) setTotal(0) } finally { - setLoading(false) + if (requestId === requestIdRef.current) { + setLoading(false) + } } - }, [isAdmin, page, pageSize, keyword]) + }, [debouncedKeyword, isAdmin, page, pageSize]) /** * Complete a pending order (admin only) @@ -137,14 +147,17 @@ export function useBillingHistory(options: UseBillingHistoryOptions = {}) { * Search by keyword */ const handleSearch = useCallback((newKeyword: string) => { + requestIdRef.current += 1 setKeyword(newKeyword) setPage(1) // Reset to first page when searching }, []) - // Fetch data when dependencies change + // Fetch data after the search draft has settled. useEffect(() => { + if (keyword !== debouncedKeyword) return + fetchBillingHistory() - }, [fetchBillingHistory]) + }, [debouncedKeyword, fetchBillingHistory, keyword]) return { records,