diff --git a/web/default/src/components/data-table/toolbar.tsx b/web/default/src/components/data-table/toolbar.tsx index 0c62483f..1859e602 100644 --- a/web/default/src/components/data-table/toolbar.tsx +++ b/web/default/src/components/data-table/toolbar.tsx @@ -21,6 +21,7 @@ import { useState, type ReactNode } from 'react' import { type Table } from '@tanstack/react-table' import { ChevronDown, Loader2, X as Cross2Icon } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { useDebounce } from '@/hooks' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -46,6 +47,10 @@ export type DataTableToolbarProps = { * Placeholder for the default search input. Defaults to `t('Filter...')`. */ searchPlaceholder?: string + /** + * Delay committing the default search input. Defaults to immediate updates. + */ + searchDebounceMs?: number /** * Column id to filter on. When provided, the search input filters * a specific column. When omitted, the search input updates the @@ -136,6 +141,8 @@ export type DataTableToolbarProps = { export function DataTableToolbar(props: DataTableToolbarProps) { const { t } = useTranslation() const [expanded, setExpanded] = useState(false) + const isSearchComposingRef = React.useRef(false) + const lastCommittedSearchValueRef = React.useRef('') const filters = props.filters ?? [] const hasExpandable = props.expandable != null @@ -147,26 +154,109 @@ export function DataTableToolbar(props: DataTableToolbarProps) { !!props.hasAdditionalFilters const placeholder = props.searchPlaceholder ?? t('Filter...') + const currentSearchValue = props.searchKey + ? ((props.table.getColumn(props.searchKey)?.getFilterValue() as string) ?? + '') + : ((props.table.getState().globalFilter as string | undefined) ?? '') + + const [searchValue, setSearchValue] = useState(currentSearchValue) + const [pendingSearchValue, setPendingSearchValue] = + useState(currentSearchValue) + const searchDebounceMs = Math.max(0, props.searchDebounceMs ?? 0) + const debouncedSearchValue = useDebounce( + pendingSearchValue, + searchDebounceMs + ) + + React.useEffect(() => { + lastCommittedSearchValueRef.current = currentSearchValue + if (!isSearchComposingRef.current) { + setSearchValue(currentSearchValue) + } + setPendingSearchValue(currentSearchValue) + }, [currentSearchValue]) + + const commitSearchValue = React.useCallback( + (value: string) => { + if (value === lastCommittedSearchValueRef.current) { + return + } + + lastCommittedSearchValueRef.current = value + + if (props.searchKey) { + props.table.getColumn(props.searchKey)?.setFilterValue(value) + return + } + + props.table.setGlobalFilter(value) + }, + [props.searchKey, props.table] + ) + + React.useEffect(() => { + if ( + searchDebounceMs <= 0 || + isSearchComposingRef.current || + debouncedSearchValue !== pendingSearchValue + ) { + return + } + + commitSearchValue(debouncedSearchValue) + }, [ + commitSearchValue, + debouncedSearchValue, + pendingSearchValue, + searchDebounceMs, + ]) + + const queueSearchValue = (value: string) => { + setPendingSearchValue(value) + + if (searchDebounceMs <= 0) { + commitSearchValue(value) + } + } + + const handleSearchChange = (event: React.ChangeEvent) => { + const value = event.target.value + setSearchValue(value) + + if (!isSearchComposingRef.current) { + queueSearchValue(value) + } + } + + const handleSearchCompositionStart = () => { + isSearchComposingRef.current = true + } + + const handleSearchCompositionEnd = ( + event: React.CompositionEvent + ) => { + isSearchComposingRef.current = false + const value = event.currentTarget.value + setSearchValue(value) + queueSearchValue(value) + } const searchInput = props.searchKey ? ( - props.table - .getColumn(props.searchKey!) - ?.setFilterValue(event.target.value) - } + value={searchValue} + onChange={handleSearchChange} + onCompositionStart={handleSearchCompositionStart} + onCompositionEnd={handleSearchCompositionEnd} className='w-full sm:w-[200px] lg:w-[240px]' /> ) : ( props.table.setGlobalFilter(event.target.value)} + value={searchValue} + onChange={handleSearchChange} + onCompositionStart={handleSearchCompositionStart} + onCompositionEnd={handleSearchCompositionEnd} className='w-full sm:w-[200px] lg:w-[240px]' /> ) @@ -186,6 +276,10 @@ export function DataTableToolbar(props: DataTableToolbarProps) { }) const handleReset = () => { + isSearchComposingRef.current = false + setSearchValue('') + setPendingSearchValue('') + lastCommittedSearchValueRef.current = '' props.table.resetColumnFilters() props.table.setGlobalFilter('') props.onReset?.() diff --git a/web/default/src/features/channels/components/channels-table.tsx b/web/default/src/features/channels/components/channels-table.tsx index 399732aa..179f5023 100644 --- a/web/default/src/features/channels/components/channels-table.tsx +++ b/web/default/src/features/channels/components/channels-table.tsx @@ -16,7 +16,14 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useState, useMemo, useEffect } from 'react' +import { + useState, + useMemo, + useEffect, + useRef, + type ChangeEvent, + type CompositionEvent, +} from 'react' import { useQuery } from '@tanstack/react-query' import { getRouteApi } from '@tanstack/react-router' import { @@ -124,17 +131,26 @@ export function ChannelsTable() { (columnFilters.find((f) => f.id === 'model')?.value as string) || '' // Local state for immediate input feedback + const isModelFilterComposingRef = useRef(false) const [modelFilterInput, setModelFilterInput] = useState(modelFilterFromUrl) - const debouncedModelFilter = useDebounce(modelFilterInput, 500) + const [modelFilterPendingValue, setModelFilterPendingValue] = + useState(modelFilterFromUrl) + const debouncedModelFilter = useDebounce(modelFilterPendingValue, 500) // Sync local input with URL when URL changes (e.g., from back/forward navigation) useEffect(() => { - setModelFilterInput(modelFilterFromUrl) + if (!isModelFilterComposingRef.current) { + setModelFilterInput(modelFilterFromUrl) + } + setModelFilterPendingValue(modelFilterFromUrl) }, [modelFilterFromUrl]) // Update URL when debounced value changes useEffect(() => { - if (debouncedModelFilter !== modelFilterFromUrl) { + if ( + debouncedModelFilter === modelFilterPendingValue && + debouncedModelFilter !== modelFilterFromUrl + ) { onColumnFiltersChange((prev) => { const filtered = prev.filter((f) => f.id !== 'model') return debouncedModelFilter @@ -142,7 +158,34 @@ export function ChannelsTable() { : filtered }) } - }, [debouncedModelFilter, modelFilterFromUrl, onColumnFiltersChange]) + }, [ + debouncedModelFilter, + modelFilterFromUrl, + modelFilterPendingValue, + onColumnFiltersChange, + ]) + + const handleModelFilterChange = (event: ChangeEvent) => { + const value = event.target.value + setModelFilterInput(value) + + if (!isModelFilterComposingRef.current) { + setModelFilterPendingValue(value) + } + } + + const handleModelFilterCompositionStart = () => { + isModelFilterComposingRef.current = true + } + + const handleModelFilterCompositionEnd = ( + event: CompositionEvent + ) => { + isModelFilterComposingRef.current = false + const value = event.currentTarget.value + setModelFilterInput(value) + setModelFilterPendingValue(value) + } const modelFilter = modelFilterFromUrl @@ -385,11 +428,19 @@ export function ChannelsTable() { applyHeaderSize toolbarProps={{ searchPlaceholder: t('Filter by name, ID, or key...'), + searchDebounceMs: 500, + onReset: () => { + isModelFilterComposingRef.current = false + setModelFilterInput('') + setModelFilterPendingValue('') + }, additionalSearch: ( setModelFilterInput(e.target.value)} + onChange={handleModelFilterChange} + onCompositionStart={handleModelFilterCompositionStart} + onCompositionEnd={handleModelFilterCompositionEnd} className='w-full sm:w-[150px] lg:w-[180px]' /> ),