diff --git a/src/components/table/README.md b/src/components/table/README.md index 505d8dbb587..f90e28bd515 100644 --- a/src/components/table/README.md +++ b/src/components/table/README.md @@ -2,7 +2,7 @@ > For displaying tabular data, `` 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 > [``](#light-weight-tables) and [``](#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, ``, 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, `` 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 diff --git a/src/components/table/helpers/mixin-filtering.js b/src/components/table/helpers/mixin-filtering.js index 8106dc5fa15..78c121efafb 100644 --- a/src/components/table/helpers/mixin-filtering.js +++ b/src/components/table/helpers/mixin-filtering.js @@ -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 diff --git a/src/components/table/table-filtering.spec.js b/src/components/table/table-filtering.spec.js index 07cd472c7c4..a895e01152d 100644 --- a/src/components/table/table-filtering.spec.js +++ b/src/components/table/table-filtering.spec.js @@ -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() + }) })