Skip to content

Commit 03536a5

Browse files
authored
feat(b-table): add filter-debounce prop for debouncing filter updates (bootstrap-vue#3891)
1 parent 4672cc2 commit 03536a5

File tree

3 files changed

+156
-21
lines changed

3 files changed

+156
-21
lines changed

src/components/table/README.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
> For displaying tabular data, `<b-table>` supports pagination, filtering, sorting, custom
44
> rendering, various style options, events, and asynchronous data. For simple display of tabular
5-
> data without all the fancy features, BootstrapVue provides lightweight alternative components
5+
> data without all the fancy features, BootstrapVue provides two lightweight alternative components
66
> [`<b-table-lite>`](#light-weight-tables) and [`<b-table-simple>`](#simple-tables).
77
88
**Example: Basic usage**
@@ -800,8 +800,8 @@ function.
800800

801801
Scoped field slots give you greater control over how the record data appears. You can use scoped
802802
slots to provided custom rendering for a particular field. If you want to add an extra field which
803-
does not exist in the records, just add it to the [`fields`](#fields-column-definitions) array,
804-
and then reference the field(s) in the scoped slot(s). Scoped field slots use the following naming
803+
does not exist in the records, just add it to the [`fields`](#fields-column-definitions) array, and
804+
then reference the field(s) in the scoped slot(s). Scoped field slots use the following naming
805805
syntax: `'cell[' + field key + ']'`.
806806

807807
You can use the default _fall-back_ scoped slot `'cell[]'` to format any cells that do not have an
@@ -1984,6 +1984,25 @@ When local filtering is applied, and the resultant number of items change, `<b-t
19841984

19851985
Setting the prop `filter` to null or an empty string will clear local items filtering.
19861986

1987+
### Debouncing filter criteria changes
1988+
1989+
If you have a text input tied to the `filter` prop of `<b-table>`, the filtering process will occur
1990+
for each character typed by the user. With large items datasets, this process can take a while and
1991+
may cause the text input to appear sluggish.
1992+
1993+
To help alleviate this type of situation, `<b-table>` accepts a debounce timout value (in
1994+
milliseconds) via the `filter-debounce` prop. The default is `0` (milliseconds). When a value
1995+
greater than `0` is provided, the filter will wait for that time before updating the table results.
1996+
If the value of the `filter` prop changes before this timeout expires, the filtering will be once
1997+
again delayed until the debounce timeout expires.
1998+
1999+
When used, the suggested value of `filter-debounce` should be in the range of `100` to `200`
2000+
milliseconds, but other values may be more suitable for your use case.
2001+
2002+
The use of this prop can be beneficial when using provider filtering with
2003+
[items provider functions](#using-items-provider-functions), to help reduce the number of calls to
2004+
your back end API.
2005+
19872006
### Filtering notes
19882007

19892008
See the [Complete Example](#complete-example) below for an example of using the `filter` feature.
@@ -2173,6 +2192,9 @@ of records.
21732192
`filter` props on `b-table` to trigger the provider update function call (unless you have the
21742193
respective `no-provider-*` prop set to `true`).
21752194
- The `no-local-sorting` prop has no effect when `items` is a provider function.
2195+
- When using provider filtering, you may find that setting the
2196+
[`filter-debounce` prop](#debouncing-filter-criteria-changes) to a value greater than `100` ms
2197+
will help minimize the number of calls to your back end API as the user types in the criteria.
21762198

21772199
### Force refreshing of table data
21782200

src/components/table/helpers/mixin-filtering.js

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,19 @@ export default {
2121
filterIncludedFields: {
2222
type: Array
2323
// default: undefined
24+
},
25+
filterDebounce: {
26+
type: [Number, String],
27+
default: 0,
28+
validator: val => /^\d+/.test(String(val))
2429
}
2530
},
2631
data() {
2732
return {
2833
// Flag for displaying which empty slot to show and some event triggering
29-
isFiltered: false
34+
isFiltered: false,
35+
// Where we store the copy of the filter citeria after debouncing
36+
localFilter: null
3037
}
3138
},
3239
computed: {
@@ -36,6 +43,9 @@ export default {
3643
computedFilterIncluded() {
3744
return this.filterIncludedFields ? concat(this.filterIncludedFields).filter(Boolean) : null
3845
},
46+
computedFilterDebounce() {
47+
return parseInt(this.filterDebounce, 10) || 0
48+
},
3949
localFiltering() {
4050
return this.hasProvider ? !!this.noProviderFiltering : true
4151
},
@@ -47,22 +57,6 @@ export default {
4757
localFilter: this.localFilter
4858
}
4959
},
50-
// Sanitized/normalized version of filter prop
51-
localFilter() {
52-
// Using internal filter function, which only accepts string or RegExp
53-
if (
54-
this.localFiltering &&
55-
!isFunction(this.filterFunction) &&
56-
!(isString(this.filter) || isRegExp(this.filter))
57-
) {
58-
return ''
59-
}
60-
61-
// Could be a string, object or array, as needed by external filter function
62-
// We use `cloneDeep` to ensure we have a new copy of an object or array
63-
// without Vue reactive observers
64-
return cloneDeep(this.filter)
65-
},
6660
// Sanitized/normalize filter-function prop
6761
localFilterFn() {
6862
// Return `null` to signal to use internal filter function
@@ -72,13 +66,14 @@ export default {
7266
// Returns the original `localItems` array if not sorting
7367
filteredItems() {
7468
const items = this.localItems || []
69+
// Note the criteria is debounced
70+
const criteria = this.filterSanitize(this.localFilter)
7571

7672
// Resolve the filtering function, when requested
7773
// We prefer the provided filtering function and fallback to the internal one
7874
// When no filtering criteria is specified the filtering factories will return `null`
7975
let filterFn = null
8076
if (this.localFiltering) {
81-
const criteria = this.localFilter
8277
filterFn =
8378
this.filterFnFactory(this.localFilterFn, criteria) ||
8479
this.defaultFilterFnFactory(criteria)
@@ -94,6 +89,32 @@ export default {
9489
}
9590
},
9691
watch: {
92+
// Watch for debounce being set to 0
93+
computedFilterDebounce(newVal, oldVal) {
94+
if (!newVal && this.filterTimer) {
95+
clearTimeout(this.filterTimer)
96+
this.filterTimer = null
97+
this.localFilter = this.filter
98+
}
99+
},
100+
// Watch for changes to the filter criteria, and debounce if necessary
101+
filter(newFilter, oldFilter) {
102+
const timeout = this.computedFilterDebounce
103+
if (this.filterTimer) {
104+
clearTimeout(this.filterTimer)
105+
this.filterTimer = null
106+
}
107+
if (timeout) {
108+
// If we have a debounce time, delay the update of this.localFilter
109+
this.filterTimer = setTimeout(() => {
110+
this.filterTimer = null
111+
this.localFilter = this.filterSanitize(this.filter)
112+
}, timeout)
113+
} else {
114+
// Otherwise, immediately update this.localFilter
115+
this.localFilter = this.filterSanitize(this.filter)
116+
}
117+
},
97118
// Watch for changes to the filter criteria and filtered items vs localItems).
98119
// And set visual state and emit events as required
99120
filteredCheck({ filteredItems, localItems, localFilter }) {
@@ -123,13 +144,42 @@ export default {
123144
}
124145
},
125146
created() {
147+
// Create non-reactive prop where we store the debounce timer id
148+
this.filterTimer = null
149+
// If filter is "pre-set", set the criteria
150+
// This will trigger any watchers/dependants
151+
this.localFilter = this.filterSanitize(this.filter)
126152
// Set the initial filtered state.
127153
// In a nextTick so that we trigger a filtered event if needed
128154
this.$nextTick(() => {
129155
this.isFiltered = Boolean(this.localFilter)
130156
})
131157
},
158+
beforeDestroy() {
159+
/* istanbul ignore next */
160+
if (this.filterTimer) {
161+
clearTimeout(this.filterTimer)
162+
this.filterTimer = null
163+
}
164+
},
132165
methods: {
166+
filterSanitize(criteria) {
167+
// Sanitizes filter criteria based on internal or external filtering
168+
if (
169+
this.localFiltering &&
170+
!isFunction(this.filterFunction) &&
171+
!(isString(criteria) || isRegExp(criteria))
172+
) {
173+
// If using internal filter function, which only accepts string or RegExp
174+
// return null to signify no filter
175+
return null
176+
}
177+
178+
// Could be a string, object or array, as needed by external filter function
179+
// We use `cloneDeep` to ensure we have a new copy of an object or array
180+
// without Vue's reactive observers
181+
return cloneDeep(criteria)
182+
},
133183
// Filter Function factories
134184
filterFnFactory(filterFn, criteria) {
135185
// Wrapper factory for external filter functions

src/components/table/table-filtering.spec.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,4 +235,67 @@ describe('table > filtering', () => {
235235

236236
wrapper.destroy()
237237
})
238+
239+
it('filter debouncing works', async () => {
240+
jest.useFakeTimers()
241+
const wrapper = mount(BTable, {
242+
propsData: {
243+
fields: testFields,
244+
items: testItems,
245+
filterDebounce: 100 // 100ms
246+
}
247+
})
248+
expect(wrapper).toBeDefined()
249+
expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
250+
expect(wrapper.findAll('tbody > tr').length).toBe(3)
251+
expect(wrapper.vm.filterTimer).toBe(null)
252+
await waitNT(wrapper.vm)
253+
expect(wrapper.emitted('input')).toBeDefined()
254+
expect(wrapper.emitted('input').length).toBe(1)
255+
expect(wrapper.emitted('input')[0][0]).toEqual(testItems)
256+
expect(wrapper.vm.filterTimer).toBe(null)
257+
258+
// Set filter to a single character
259+
wrapper.setProps({
260+
filter: '1'
261+
})
262+
await waitNT(wrapper.vm)
263+
expect(wrapper.emitted('input').length).toBe(1)
264+
expect(wrapper.vm.filterTimer).not.toBe(null)
265+
266+
// Change filter
267+
wrapper.setProps({
268+
filter: 'z'
269+
})
270+
await waitNT(wrapper.vm)
271+
expect(wrapper.emitted('input').length).toBe(1)
272+
expect(wrapper.vm.filterTimer).not.toBe(null)
273+
274+
jest.runTimersToTime(101)
275+
await waitNT(wrapper.vm)
276+
expect(wrapper.emitted('input').length).toBe(2)
277+
expect(wrapper.emitted('input')[1][0]).toEqual([testItems[2]])
278+
expect(wrapper.vm.filterTimer).toBe(null)
279+
280+
// Change filter
281+
wrapper.setProps({
282+
filter: '1'
283+
})
284+
await waitNT(wrapper.vm)
285+
expect(wrapper.vm.filterTimer).not.toBe(null)
286+
expect(wrapper.emitted('input').length).toBe(2)
287+
288+
// Change filter-debounce to no debouncing
289+
wrapper.setProps({
290+
filterDebounce: 0
291+
})
292+
await waitNT(wrapper.vm)
293+
// Should clear the pending timer
294+
expect(wrapper.vm.filterTimer).toBe(null)
295+
// Should immediately filter the items
296+
expect(wrapper.emitted('input').length).toBe(3)
297+
expect(wrapper.emitted('input')[2][0]).toEqual([testItems[1]])
298+
299+
wrapper.destroy()
300+
})
238301
})

0 commit comments

Comments
 (0)