diff --git a/packages/bindx-dataview/src/filterHooks.ts b/packages/bindx-dataview/src/filterHooks.ts index 11b81614..feefb6bb 100644 --- a/packages/bindx-dataview/src/filterHooks.ts +++ b/packages/bindx-dataview/src/filterHooks.ts @@ -264,28 +264,42 @@ export function useDataViewTextFilterInput({ name, debounceMs = 500 }: { debounceMs?: number }): UseDataViewTextFilterInputResult { const [state, setFilter] = useDataViewFilter(name) - const [value, setValue] = useState(state?.query ?? '') + const query = state?.query ?? '' + const [value, setValue] = useState(query) const timerRef = useRef | 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): 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 } } diff --git a/tests/react/dataview/textFilterInputDebounce.test.tsx b/tests/react/dataview/textFilterInputDebounce.test.tsx new file mode 100644 index 00000000..ca0e28f2 --- /dev/null +++ b/tests/react/dataview/textFilterInputDebounce.test.tsx @@ -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') +const DEBOUNCE_MS = 20 + +function AppliedQuery(): ReactElement { + const [state, setFilter] = useDataViewFilter(QUERY_FILTER_NAME) + return ( + <> + {state?.query ?? ''} + + + ) +} + +function renderGrid(): HTMLElement { + const adapter = new MockAdapter({ + Article: { a1: { id: 'a1', title: 'Alpha' } }, + }, { delay: 0 }) + const { container } = render( + + + {it => ( + <> + + + + + + + + )} + + , + ) + 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 { + 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('') + }) +})