Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 105 additions & 11 deletions web/default/src/components/data-table/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -46,6 +47,10 @@ export type DataTableToolbarProps<TData> = {
* 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
Expand Down Expand Up @@ -136,6 +141,8 @@ export type DataTableToolbarProps<TData> = {
export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
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
Expand All @@ -147,26 +154,109 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
!!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<HTMLInputElement>) => {
const value = event.target.value
setSearchValue(value)

if (!isSearchComposingRef.current) {
queueSearchValue(value)
}
}

const handleSearchCompositionStart = () => {
isSearchComposingRef.current = true
}

const handleSearchCompositionEnd = (
event: React.CompositionEvent<HTMLInputElement>
) => {
isSearchComposingRef.current = false
const value = event.currentTarget.value
setSearchValue(value)
queueSearchValue(value)
}

const searchInput = props.searchKey ? (
<Input
placeholder={placeholder}
value={
(props.table.getColumn(props.searchKey)?.getFilterValue() as string) ??
''
}
onChange={(event) =>
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]'
/>
) : (
<Input
placeholder={placeholder}
value={props.table.getState().globalFilter ?? ''}
onChange={(event) => props.table.setGlobalFilter(event.target.value)}
value={searchValue}
onChange={handleSearchChange}
onCompositionStart={handleSearchCompositionStart}
onCompositionEnd={handleSearchCompositionEnd}
className='w-full sm:w-[200px] lg:w-[240px]'
/>
)
Expand All @@ -186,6 +276,10 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
})

const handleReset = () => {
isSearchComposingRef.current = false
setSearchValue('')
setPendingSearchValue('')
lastCommittedSearchValueRef.current = ''
props.table.resetColumnFilters()
props.table.setGlobalFilter('')
props.onReset?.()
Expand Down
63 changes: 57 additions & 6 deletions web/default/src/features/channels/components/channels-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.

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 {
Expand Down Expand Up @@ -124,25 +131,61 @@ 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
? [...filtered, { id: 'model', value: debouncedModelFilter }]
: filtered
})
}
}, [debouncedModelFilter, modelFilterFromUrl, onColumnFiltersChange])
}, [
debouncedModelFilter,
modelFilterFromUrl,
modelFilterPendingValue,
onColumnFiltersChange,
])

const handleModelFilterChange = (event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
setModelFilterInput(value)

if (!isModelFilterComposingRef.current) {
setModelFilterPendingValue(value)
}
}

const handleModelFilterCompositionStart = () => {
isModelFilterComposingRef.current = true
}

const handleModelFilterCompositionEnd = (
event: CompositionEvent<HTMLInputElement>
) => {
isModelFilterComposingRef.current = false
const value = event.currentTarget.value
setModelFilterInput(value)
setModelFilterPendingValue(value)
}

const modelFilter = modelFilterFromUrl

Expand Down Expand Up @@ -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: (
<Input
placeholder={t('Filter by model...')}
value={modelFilterInput}
onChange={(e) => setModelFilterInput(e.target.value)}
onChange={handleModelFilterChange}
onCompositionStart={handleModelFilterCompositionStart}
onCompositionEnd={handleModelFilterCompositionEnd}
className='w-full sm:w-[150px] lg:w-[180px]'
/>
),
Expand Down