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
32 changes: 23 additions & 9 deletions packages/bindx-dataview/src/filterHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,28 +264,42 @@ export function useDataViewTextFilterInput({ name, debounceMs = 500 }: {
debounceMs?: number
}): UseDataViewTextFilterInputResult {
const [state, setFilter] = useDataViewFilter<TextFilterArtifact>(name)
const [value, setValue] = useState(state?.query ?? '')
const query = state?.query ?? ''
const [value, setValue] = useState(query)
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
// The last query this input applied; a different one in the filter state came from outside.
const appliedQueryRef = useRef(query)

const cancelPendingQuery = useCallback((): void => {
clearTimeout(timerRef.current)
timerRef.current = undefined
}, [])

useEffect(() => {
if (!timerRef.current) {
setValue(state?.query ?? '')
}
}, [state?.query])
if (query === appliedQueryRef.current) return
appliedQueryRef.current = query
cancelPendingQuery()
setValue(query)
}, [query, cancelPendingQuery])

const applyQuery = useCallback((newQuery: string): void => {
appliedQueryRef.current = newQuery
setFilter(it => ({ mode: 'contains' as const, ...it, query: newQuery }))
}, [setFilter])

const onChange = useCallback((e: ChangeEvent<HTMLInputElement>): void => {
const newValue = e.target.value
cancelPendingQuery()
if (debounceMs && newValue) {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
timerRef.current = undefined
setFilter(it => ({ mode: 'contains' as const, ...it, query: newValue }))
applyQuery(newValue)
}, debounceMs)
} else {
setFilter(it => ({ mode: 'contains' as const, ...it, query: newValue }))
applyQuery(newValue)
}
setValue(newValue)
}, [debounceMs, setFilter])
}, [debounceMs, applyQuery, cancelPendingQuery])

return { value, onChange }
}
Expand Down
121 changes: 121 additions & 0 deletions tests/react/dataview/textFilterInputDebounce.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Issue #142: a debounce timer left pending by the text filter input applied a stale query
// after the box was emptied or the filter was reset from outside.
import '../../setup'
import { describe, test, expect, afterEach } from 'bun:test'
import { render, waitFor, cleanup, act, fireEvent } from '@testing-library/react'
import React, { type ReactElement } from 'react'
import { BindxProvider, MockAdapter, defineSchema, scalar } from '@contember/bindx-react'
import { entityDef } from '@contember/bindx'
import type { TextFilterArtifact } from '@contember/bindx'
import {
DataGrid,
DataGridTextColumn,
DataViewTextFilterInput,
QUERY_FILTER_NAME,
useDataViewFilter,
} from '@contember/bindx-dataview'
import { getByTestId, queryByTestId, TestTable } from './helpers.js'

afterEach(() => {
cleanup()
})

interface Article {
id: string
title: string
}

const localSchema = defineSchema<{ Article: Article }>({
entities: {
Article: {
fields: {
id: scalar(),
title: scalar(),
},
},
},
})

const ArticleEntity = entityDef<Article>('Article')
const DEBOUNCE_MS = 20

function AppliedQuery(): ReactElement {
const [state, setFilter] = useDataViewFilter<TextFilterArtifact>(QUERY_FILTER_NAME)
return (
<>
<span data-testid="applied-query">{state?.query ?? ''}</span>
<button data-testid="reset" onClick={() => setFilter(undefined)}>Reset</button>
</>
)
}

function renderGrid(): HTMLElement {
const adapter = new MockAdapter({
Article: { a1: { id: 'a1', title: 'Alpha' } },
}, { delay: 0 })
const { container } = render(
<BindxProvider adapter={adapter} schema={localSchema}>
<DataGrid entity={ArticleEntity}>
{it => (
<>
<DataGridTextColumn field={it.title} header="Title" />
<DataViewTextFilterInput name={QUERY_FILTER_NAME} debounceMs={DEBOUNCE_MS}>
<input data-testid="search" />
</DataViewTextFilterInput>
<AppliedQuery />
<TestTable />
</>
)}
</DataGrid>
</BindxProvider>,
)
return container
}

function type(container: HTMLElement, value: string): void {
fireEvent.change(getByTestId(container, 'search'), { target: { value } })
}

function inputValue(container: HTMLElement): string {
const input = getByTestId(container, 'search')
if (!(input instanceof HTMLInputElement)) throw new Error('search is not an input')
return input.value
}

async function waitPastDebounce(): Promise<void> {
await act(async () => {
await new Promise(resolve => setTimeout(resolve, DEBOUNCE_MS * 3))
})
}

describe('text filter input debounce', () => {
test('emptying the box within the debounce does not bring the previous letter back', async () => {
const container = renderGrid()
await waitFor(() => expect(queryByTestId(container, 'datagrid-table')).not.toBeNull())

type(container, 'ab')
await waitFor(() => expect(getByTestId(container, 'applied-query').textContent).toBe('ab'))

type(container, 'a')
type(container, '')
await waitPastDebounce()

expect(getByTestId(container, 'applied-query').textContent).toBe('')
expect(inputValue(container)).toBe('')
})

test('a reset from outside within the debounce drops the pending value', async () => {
const container = renderGrid()
await waitFor(() => expect(queryByTestId(container, 'datagrid-table')).not.toBeNull())

type(container, 'ab')
await waitFor(() => expect(getByTestId(container, 'applied-query').textContent).toBe('ab'))

type(container, 'abc')
fireEvent.click(getByTestId(container, 'reset'))
await waitPastDebounce()

expect(getByTestId(container, 'applied-query').textContent).toBe('')
expect(inputValue(container)).toBe('')
})
})
Loading