Skip to content
Permalink
Browse files

feat(b-table): add `filter-debounce` prop for debouncing filter updat…

…es (#3891)
  • Loading branch information...
tmorehouse committed Aug 16, 2019
1 parent 4672cc2 commit 03536a504208f094f205a5a2f1ab64ccbb4dbccf
@@ -2,7 +2,7 @@

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

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

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

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

### Debouncing filter criteria changes

If you have a text input tied to the `filter` prop of `<b-table>`, the filtering process will occur
for each character typed by the user. With large items datasets, this process can take a while and
may cause the text input to appear sluggish.

To help alleviate this type of situation, `<b-table>` accepts a debounce timout value (in
milliseconds) via the `filter-debounce` prop. The default is `0` (milliseconds). When a value
greater than `0` is provided, the filter will wait for that time before updating the table results.
If the value of the `filter` prop changes before this timeout expires, the filtering will be once
again delayed until the debounce timeout expires.

When used, the suggested value of `filter-debounce` should be in the range of `100` to `200`
milliseconds, but other values may be more suitable for your use case.

The use of this prop can be beneficial when using provider filtering with
[items provider functions](#using-items-provider-functions), to help reduce the number of calls to
your back end API.

### Filtering notes

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

### Force refreshing of table data

@@ -21,12 +21,19 @@ export default {
filterIncludedFields: {
type: Array
// default: undefined
},
filterDebounce: {
type: [Number, String],
default: 0,
validator: val => /^\d+/.test(String(val))
}
},
data() {
return {
// Flag for displaying which empty slot to show and some event triggering
isFiltered: false
isFiltered: false,
// Where we store the copy of the filter citeria after debouncing
localFilter: null
}
},
computed: {
@@ -36,6 +43,9 @@ export default {
computedFilterIncluded() {
return this.filterIncludedFields ? concat(this.filterIncludedFields).filter(Boolean) : null
},
computedFilterDebounce() {
return parseInt(this.filterDebounce, 10) || 0
},
localFiltering() {
return this.hasProvider ? !!this.noProviderFiltering : true
},
@@ -47,22 +57,6 @@ export default {
localFilter: this.localFilter
}
},
// Sanitized/normalized version of filter prop
localFilter() {
// Using internal filter function, which only accepts string or RegExp
if (
this.localFiltering &&
!isFunction(this.filterFunction) &&
!(isString(this.filter) || isRegExp(this.filter))
) {
return ''
}

// Could be a string, object or array, as needed by external filter function
// We use `cloneDeep` to ensure we have a new copy of an object or array
// without Vue reactive observers
return cloneDeep(this.filter)
},
// Sanitized/normalize filter-function prop
localFilterFn() {
// Return `null` to signal to use internal filter function
@@ -72,13 +66,14 @@ export default {
// Returns the original `localItems` array if not sorting
filteredItems() {
const items = this.localItems || []
// Note the criteria is debounced
const criteria = this.filterSanitize(this.localFilter)

// Resolve the filtering function, when requested
// We prefer the provided filtering function and fallback to the internal one
// When no filtering criteria is specified the filtering factories will return `null`
let filterFn = null
if (this.localFiltering) {
const criteria = this.localFilter
filterFn =
this.filterFnFactory(this.localFilterFn, criteria) ||
this.defaultFilterFnFactory(criteria)
@@ -94,6 +89,32 @@ export default {
}
},
watch: {
// Watch for debounce being set to 0
computedFilterDebounce(newVal, oldVal) {
if (!newVal && this.filterTimer) {
clearTimeout(this.filterTimer)
this.filterTimer = null
this.localFilter = this.filter
}
},
// Watch for changes to the filter criteria, and debounce if necessary
filter(newFilter, oldFilter) {
const timeout = this.computedFilterDebounce
if (this.filterTimer) {
clearTimeout(this.filterTimer)
this.filterTimer = null
}
if (timeout) {
// If we have a debounce time, delay the update of this.localFilter
this.filterTimer = setTimeout(() => {
this.filterTimer = null
this.localFilter = this.filterSanitize(this.filter)
}, timeout)
} else {
// Otherwise, immediately update this.localFilter
this.localFilter = this.filterSanitize(this.filter)
}
},
// Watch for changes to the filter criteria and filtered items vs localItems).
// And set visual state and emit events as required
filteredCheck({ filteredItems, localItems, localFilter }) {
@@ -123,13 +144,42 @@ export default {
}
},
created() {
// Create non-reactive prop where we store the debounce timer id
this.filterTimer = null
// If filter is "pre-set", set the criteria
// This will trigger any watchers/dependants
this.localFilter = this.filterSanitize(this.filter)
// Set the initial filtered state.
// In a nextTick so that we trigger a filtered event if needed
this.$nextTick(() => {
this.isFiltered = Boolean(this.localFilter)
})
},
beforeDestroy() {
/* istanbul ignore next */
if (this.filterTimer) {
clearTimeout(this.filterTimer)
this.filterTimer = null
}
},
methods: {
filterSanitize(criteria) {
// Sanitizes filter criteria based on internal or external filtering
if (
this.localFiltering &&
!isFunction(this.filterFunction) &&
!(isString(criteria) || isRegExp(criteria))
) {
// If using internal filter function, which only accepts string or RegExp
// return null to signify no filter
return null
}

// Could be a string, object or array, as needed by external filter function
// We use `cloneDeep` to ensure we have a new copy of an object or array
// without Vue's reactive observers
return cloneDeep(criteria)
},
// Filter Function factories
filterFnFactory(filterFn, criteria) {
// Wrapper factory for external filter functions
@@ -235,4 +235,67 @@ describe('table > filtering', () => {

wrapper.destroy()
})

it('filter debouncing works', async () => {
jest.useFakeTimers()
const wrapper = mount(BTable, {
propsData: {
fields: testFields,
items: testItems,
filterDebounce: 100 // 100ms
}
})
expect(wrapper).toBeDefined()
expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
expect(wrapper.findAll('tbody > tr').length).toBe(3)
expect(wrapper.vm.filterTimer).toBe(null)
await waitNT(wrapper.vm)
expect(wrapper.emitted('input')).toBeDefined()
expect(wrapper.emitted('input').length).toBe(1)
expect(wrapper.emitted('input')[0][0]).toEqual(testItems)
expect(wrapper.vm.filterTimer).toBe(null)

// Set filter to a single character
wrapper.setProps({
filter: '1'
})
await waitNT(wrapper.vm)
expect(wrapper.emitted('input').length).toBe(1)
expect(wrapper.vm.filterTimer).not.toBe(null)

// Change filter
wrapper.setProps({
filter: 'z'
})
await waitNT(wrapper.vm)
expect(wrapper.emitted('input').length).toBe(1)
expect(wrapper.vm.filterTimer).not.toBe(null)

jest.runTimersToTime(101)
await waitNT(wrapper.vm)
expect(wrapper.emitted('input').length).toBe(2)
expect(wrapper.emitted('input')[1][0]).toEqual([testItems[2]])
expect(wrapper.vm.filterTimer).toBe(null)

// Change filter
wrapper.setProps({
filter: '1'
})
await waitNT(wrapper.vm)
expect(wrapper.vm.filterTimer).not.toBe(null)
expect(wrapper.emitted('input').length).toBe(2)

// Change filter-debounce to no debouncing
wrapper.setProps({
filterDebounce: 0
})
await waitNT(wrapper.vm)
// Should clear the pending timer
expect(wrapper.vm.filterTimer).toBe(null)
// Should immediately filter the items
expect(wrapper.emitted('input').length).toBe(3)
expect(wrapper.emitted('input')[2][0]).toEqual([testItems[1]])

wrapper.destroy()
})
})

0 comments on commit 03536a5

Please sign in to comment.
You can’t perform that action at this time.