11/**
22 * @vitest -environment jsdom
33 */
4- import { act , createRef , type Ref } from 'react'
4+ import { act } from 'react'
55import { createRoot , type Root } from 'react-dom/client'
66import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
77import type { ColumnDefinition , TablePredicate } from '@/lib/table'
8- import {
9- FILTER_DEBOUNCE_MS ,
10- TableFilter ,
11- type TableFilterHandle ,
12- } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter'
8+ import { TableFilter } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter'
139
1410const COLUMNS : ColumnDefinition [ ] = [ { id : 'col-name' , name : 'Name' , type : 'string' } ]
1511
@@ -18,7 +14,6 @@ let root: Root
1814
1915beforeEach ( ( ) => {
2016 globalThis . IS_REACT_ACT_ENVIRONMENT = true
21- vi . useFakeTimers ( )
2217 container = document . createElement ( 'div' )
2318 document . body . appendChild ( container )
2419 root = createRoot ( container )
@@ -27,41 +22,70 @@ beforeEach(() => {
2722afterEach ( ( ) => {
2823 act ( ( ) => root . unmount ( ) )
2924 container . remove ( )
30- vi . useRealTimers ( )
3125} )
3226
3327function renderFilter (
3428 onChange : ( filter : TablePredicate | null ) => void ,
35- filter : TablePredicate | null = null ,
36- ref ?: Ref < TableFilterHandle >
29+ filter : TablePredicate | null = null
3730) {
3831 act ( ( ) => {
39- root . render ( < TableFilter ref = { ref } columns = { COLUMNS } filter = { filter } onChange = { onChange } /> )
32+ root . render ( < TableFilter columns = { COLUMNS } filter = { filter } onChange = { onChange } /> )
4033 } )
4134}
4235
36+ function valueInput ( ) : HTMLInputElement | null {
37+ return container . querySelector < HTMLInputElement > ( 'input[placeholder="Enter a value"]' )
38+ }
39+
40+ function typeInto ( input : HTMLInputElement | null , value : string ) {
41+ if ( ! input ) return
42+ Object . getOwnPropertyDescriptor ( HTMLInputElement . prototype , 'value' ) ?. set ?. call ( input , value )
43+ input . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) )
44+ }
45+
4346describe ( 'TableFilter' , ( ) => {
44- it ( 'applies text filters after a short typing delay ' , ( ) => {
45- const onApply = vi . fn ( )
46- renderFilter ( onApply )
47- const input = container . querySelector < HTMLInputElement > ( 'input[placeholder="Enter a value"]' )
47+ it ( 'commits a typed value on blur, not per keystroke ' , ( ) => {
48+ const onChange = vi . fn ( )
49+ renderFilter ( onChange )
50+ const input = valueInput ( )
4851 expect ( input ) . not . toBeNull ( )
4952
50- act ( ( ) => {
51- if ( ! input ) return
52- Object . getOwnPropertyDescriptor ( HTMLInputElement . prototype , 'value' ) ?. set ?. call ( input , 'Ada' )
53- input . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) )
54- } )
53+ act ( ( ) => typeInto ( input , 'Ada' ) )
54+ expect ( onChange ) . not . toHaveBeenCalled ( )
5555
56- expect ( onApply ) . not . toHaveBeenCalled ( )
57- act ( ( ) => vi . advanceTimersByTime ( FILTER_DEBOUNCE_MS - 1 ) )
58- expect ( onApply ) . not . toHaveBeenCalled ( )
59- act ( ( ) => vi . advanceTimersByTime ( 1 ) )
60- expect ( onApply ) . toHaveBeenCalledWith ( {
56+ act ( ( ) => input ?. dispatchEvent ( new FocusEvent ( 'focusout' , { bubbles : true } ) ) )
57+ expect ( onChange ) . toHaveBeenCalledTimes ( 1 )
58+ expect ( onChange ) . toHaveBeenCalledWith ( {
6159 all : [ { field : 'col-name' , op : 'eq' , value : 'Ada' } ] ,
6260 } )
6361 } )
6462
63+ it ( 'commits a typed value on Enter' , ( ) => {
64+ const onChange = vi . fn ( )
65+ renderFilter ( onChange )
66+ const input = valueInput ( )
67+
68+ act ( ( ) => typeInto ( input , 'Grace' ) )
69+ act ( ( ) => input ?. dispatchEvent ( new KeyboardEvent ( 'keydown' , { key : 'Enter' , bubbles : true } ) ) )
70+
71+ expect ( onChange ) . toHaveBeenCalledTimes ( 1 )
72+ expect ( onChange ) . toHaveBeenCalledWith ( {
73+ all : [ { field : 'col-name' , op : 'eq' , value : 'Grace' } ] ,
74+ } )
75+ } )
76+
77+ it ( 'does not re-commit an unchanged value on blur after Enter' , ( ) => {
78+ const onChange = vi . fn ( )
79+ renderFilter ( onChange )
80+ const input = valueInput ( )
81+
82+ act ( ( ) => typeInto ( input , 'Ada' ) )
83+ act ( ( ) => input ?. dispatchEvent ( new KeyboardEvent ( 'keydown' , { key : 'Enter' , bubbles : true } ) ) )
84+ act ( ( ) => input ?. dispatchEvent ( new FocusEvent ( 'focusout' , { bubbles : true } ) ) )
85+
86+ expect ( onChange ) . toHaveBeenCalledTimes ( 1 )
87+ } )
88+
6589 it ( 'offers a toggleable conjunction without apply or clear actions' , ( ) => {
6690 renderFilter ( vi . fn ( ) )
6791 const addFilter = Array . from ( container . querySelectorAll ( 'button' ) ) . find ( ( button ) =>
@@ -82,51 +106,7 @@ describe('TableFilter', () => {
82106 expect ( container . textContent ) . not . toContain ( 'Clear filters' )
83107 } )
84108
85- it ( 'flushes the pending filter when the panel closes before the delay' , ( ) => {
86- const onChange = vi . fn ( )
87- const filterRef = createRef < TableFilterHandle > ( )
88- renderFilter ( onChange , null , filterRef )
89- const input = container . querySelector < HTMLInputElement > ( 'input[placeholder="Enter a value"]' )
90-
91- act ( ( ) => {
92- if ( ! input ) return
93- Object . getOwnPropertyDescriptor ( HTMLInputElement . prototype , 'value' ) ?. set ?. call ( input , 'Ada' )
94- input . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) )
95- } )
96- act ( ( ) => {
97- filterRef . current ?. flush ( )
98- } )
99-
100- expect ( onChange ) . toHaveBeenCalledTimes ( 1 )
101- expect ( onChange ) . toHaveBeenCalledWith ( {
102- all : [ { field : 'col-name' , op : 'eq' , value : 'Ada' } ] ,
103- } )
104- act ( ( ) => vi . advanceTimersByTime ( FILTER_DEBOUNCE_MS ) )
105- expect ( onChange ) . toHaveBeenCalledTimes ( 1 )
106- } )
107-
108- it ( 'cancels the previous debounce when typing continues' , ( ) => {
109- const onChange = vi . fn ( )
110- renderFilter ( onChange )
111- const input = container . querySelector < HTMLInputElement > ( 'input[placeholder="Enter a value"]' )
112- const setInput = ( value : string ) => {
113- if ( ! input ) return
114- Object . getOwnPropertyDescriptor ( HTMLInputElement . prototype , 'value' ) ?. set ?. call ( input , value )
115- input . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) )
116- }
117-
118- act ( ( ) => setInput ( 'Ada' ) )
119- act ( ( ) => vi . advanceTimersByTime ( FILTER_DEBOUNCE_MS - 1 ) )
120- act ( ( ) => setInput ( 'Grace' ) )
121- act ( ( ) => vi . advanceTimersByTime ( FILTER_DEBOUNCE_MS ) )
122-
123- expect ( onChange ) . toHaveBeenCalledTimes ( 1 )
124- expect ( onChange ) . toHaveBeenCalledWith ( {
125- all : [ { field : 'col-name' , op : 'eq' , value : 'Grace' } ] ,
126- } )
127- } )
128-
129- it ( 'clears the active filter when its last rule is removed' , ( ) => {
109+ it ( 'clears the active filter as soon as its last rule is removed' , ( ) => {
130110 const onChange = vi . fn ( )
131111 renderFilter ( onChange , {
132112 all : [ { field : 'col-name' , op : 'eq' , value : 'Ada' } ] ,
@@ -136,20 +116,15 @@ describe('TableFilter', () => {
136116 'button[aria-label="Remove filter"]'
137117 )
138118 act ( ( ) => removeButton ?. click ( ) )
139- act ( ( ) => vi . advanceTimersByTime ( FILTER_DEBOUNCE_MS ) )
140119
141120 expect ( onChange ) . toHaveBeenCalledWith ( null )
142- expect (
143- container . querySelector < HTMLInputElement > ( 'input[placeholder="Enter a value"]' ) ?. value
144- ) . toBe ( '' )
121+ expect ( valueInput ( ) ?. value ) . toBe ( '' )
145122 } )
146123
147124 it ( 'preserves saved isNull conditions instead of dropping them' , ( ) => {
148125 const onChange = vi . fn ( )
149126 renderFilter ( onChange , { all : [ { field : 'col-name' , op : 'isNull' } ] } )
150127
151- act ( ( ) => vi . advanceTimersByTime ( FILTER_DEBOUNCE_MS ) )
152-
153128 expect ( onChange ) . not . toHaveBeenCalled ( )
154129 } )
155130
@@ -166,12 +141,10 @@ describe('TableFilter', () => {
166141 ( button ) => button . textContent ?. trim ( ) === 'or'
167142 )
168143 expect ( orToggle ) . toBeDefined ( )
169-
170- act ( ( ) => vi . advanceTimersByTime ( FILTER_DEBOUNCE_MS ) )
171144 expect ( onChange ) . not . toHaveBeenCalled ( )
172145 } )
173146
174- it ( 'merges the OR groups when the conjunction is toggled back to and' , ( ) => {
147+ it ( 'merges the OR groups as soon as the conjunction is toggled back to and' , ( ) => {
175148 const onChange = vi . fn ( )
176149 renderFilter ( onChange , {
177150 any : [
@@ -184,7 +157,6 @@ describe('TableFilter', () => {
184157 ( button ) => button . textContent ?. trim ( ) === 'or'
185158 )
186159 act ( ( ) => orToggle ?. click ( ) )
187- act ( ( ) => vi . advanceTimersByTime ( FILTER_DEBOUNCE_MS ) )
188160
189161 expect ( onChange ) . toHaveBeenCalledWith ( {
190162 all : [
0 commit comments