From d1efa6351f5d41eb095dbfa1f64d899fecd5b7d9 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 15 Aug 2019 00:19:54 -0300 Subject: [PATCH 01/15] feat(b-table): add filter-debounce prop for debouncing filter updates --- .../table/helpers/mixin-filtering.js | 84 +++++++++++++++---- 1 file changed, 67 insertions(+), 17 deletions(-) diff --git a/src/components/table/helpers/mixin-filtering.js b/src/components/table/helpers/mixin-filtering.js index 8106dc5fa15..50ed2c70b1f 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 (sanitized) 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,12 +66,14 @@ export default { // Returns the original `localItems` array if not sorting filteredItems() { const items = this.localItems || [] + const criteria = 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) { + // Note the criteria is debounced const criteria = this.localFilter filterFn = this.filterFnFactory(this.localFilterFn, criteria) || @@ -94,6 +90,30 @@ export default { } }, watch: { + // Watch for debounce being set to 0 + computedFilterDebounce(newVal, oldVal) { + if (!newVal && this.filterTimer) { + clearTimeout(this.filterTimer) + this.filterTimer = null + } + }, + // 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.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 }) { @@ -122,14 +142,44 @@ export default { } } }, + beforeCreate() { + // Create non-reactive prop + // Where we store the debounce timer id + this.filterTimer = null + }, created() { + // 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() { + 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 '' + } + + // 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 From 175b76dd0460a393b843499de2096900dda7d2a5 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 15 Aug 2019 00:24:44 -0300 Subject: [PATCH 02/15] Update mixin-filtering.js --- src/components/table/helpers/mixin-filtering.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/table/helpers/mixin-filtering.js b/src/components/table/helpers/mixin-filtering.js index 50ed2c70b1f..2bd720aa0a2 100644 --- a/src/components/table/helpers/mixin-filtering.js +++ b/src/components/table/helpers/mixin-filtering.js @@ -66,6 +66,7 @@ export default { // Returns the original `localItems` array if not sorting filteredItems() { const items = this.localItems || [] + // Note the criteria is debounced const criteria = this.localFilter // Resolve the filtering function, when requested @@ -73,8 +74,6 @@ export default { // When no filtering criteria is specified the filtering factories will return `null` let filterFn = null if (this.localFiltering) { - // Note the criteria is debounced - const criteria = this.localFilter filterFn = this.filterFnFactory(this.localFilterFn, criteria) || this.defaultFilterFnFactory(criteria) From 671f5dbd8ec6b21845b4d13cbbdeca883771990e Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 15 Aug 2019 00:31:38 -0300 Subject: [PATCH 03/15] Update mixin-filtering.js --- src/components/table/helpers/mixin-filtering.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/table/helpers/mixin-filtering.js b/src/components/table/helpers/mixin-filtering.js index 2bd720aa0a2..da618652c21 100644 --- a/src/components/table/helpers/mixin-filtering.js +++ b/src/components/table/helpers/mixin-filtering.js @@ -32,7 +32,7 @@ export default { return { // Flag for displaying which empty slot to show and some event triggering isFiltered: false, - // Where we store the copy of the (sanitized) filter citeria (after debouncing) + // Where we store the copy of the filter citeria after debouncing localFilter: null } }, @@ -67,7 +67,7 @@ export default { filteredItems() { const items = this.localItems || [] // Note the criteria is debounced - const criteria = this.localFilter + const criteria = this.filterSanitize(this.localFilter) // Resolve the filtering function, when requested // We prefer the provided filtering function and fallback to the internal one @@ -106,11 +106,11 @@ export default { if (timeout) { // If we have a debounce time, delay the update of this.localFilter this.filterTimer = setTimeout(() => { - this.localFilter = this.filterSanitize(this.filter) + this.localFilter = this.filter }, timeout) } else { // Otherwise, immediately update this.localFilter - this.localFilter = this.filterSanitize(this.filter) + this.localFilter = this.filter } }, // Watch for changes to the filter criteria and filtered items vs localItems). @@ -149,7 +149,7 @@ export default { created() { // If filter is "pre-set", set the criteria // This will trigger any watchers/dependants - this.localFilter = this.filterSanitize(this.filter) + this.localFilter = this.filter // Set the initial filtered state. // In a nextTick so that we trigger a filtered event if needed this.$nextTick(() => { From 08273ddc6361ccaefb57b3657c23220a64558196 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 15 Aug 2019 00:39:17 -0300 Subject: [PATCH 04/15] Update mixin-filtering.js --- src/components/table/helpers/mixin-filtering.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/table/helpers/mixin-filtering.js b/src/components/table/helpers/mixin-filtering.js index da618652c21..5dd21c222da 100644 --- a/src/components/table/helpers/mixin-filtering.js +++ b/src/components/table/helpers/mixin-filtering.js @@ -157,6 +157,7 @@ export default { }) }, beforeDestroy() { + /* istanbul ignore next */ if (this.filterTimer) { clearTimeout(this.filterTimer) this.filterTimer = null From cec0d2e4a05ec4d91c68f05ee4d75a64ffdb8196 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 15 Aug 2019 01:04:07 -0300 Subject: [PATCH 05/15] Update table-filtering.spec.js --- src/components/table/table-filtering.spec.js | 44 ++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/components/table/table-filtering.spec.js b/src/components/table/table-filtering.spec.js index 07cd472c7c4..09b1e1f9213 100644 --- a/src/components/table/table-filtering.spec.js +++ b/src/components/table/table-filtering.spec.js @@ -235,4 +235,48 @@ 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) + + wrapper.destroy() + }) }) From 23070f39ba69c1f73c963b5ad6a98e75ab16c708 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 15 Aug 2019 01:08:07 -0300 Subject: [PATCH 06/15] Update table-filtering.spec.js --- src/components/table/table-filtering.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/table/table-filtering.spec.js b/src/components/table/table-filtering.spec.js index 09b1e1f9213..85e6e47ca6b 100644 --- a/src/components/table/table-filtering.spec.js +++ b/src/components/table/table-filtering.spec.js @@ -256,7 +256,7 @@ describe('table > filtering', () => { expect(wrapper.vm.filterTimer).toBe(null) // Set filter to a single character - wrapper.setprops({ + wrapper.setProps({ filter: '1' }) await waitNT(wrapper.vm) @@ -264,7 +264,7 @@ describe('table > filtering', () => { expect(wrapper.vm.filterTimer).not.toBe(null) // Change filter - wrapper.setprops({ + wrapper.setProps({ filter: 'z' }) await waitNT(wrapper.vm) From 13b287e5e34197f4f87a0f7371dabfbda05542be Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 15 Aug 2019 01:15:17 -0300 Subject: [PATCH 07/15] Update mixin-filtering.js --- src/components/table/helpers/mixin-filtering.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/table/helpers/mixin-filtering.js b/src/components/table/helpers/mixin-filtering.js index 5dd21c222da..fb011e1173e 100644 --- a/src/components/table/helpers/mixin-filtering.js +++ b/src/components/table/helpers/mixin-filtering.js @@ -106,6 +106,7 @@ export default { if (timeout) { // If we have a debounce time, delay the update of this.localFilter this.filterTimer = setTimeout(() => { + this.filterTimer = null this.localFilter = this.filter }, timeout) } else { From dd3165df7a14734103805a89312a247ceb2f6bc2 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 15 Aug 2019 01:44:30 -0300 Subject: [PATCH 08/15] Update README.md --- src/components/table/README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/components/table/README.md b/src/components/table/README.md index 505d8dbb587..3ac4d34cb28 100644 --- a/src/components/table/README.md +++ b/src/components/table/README.md @@ -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 be 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 sugested 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 minimze the number of calls to your back end API as the user types in the criteria. ### Force refreshing of table data From 8e03f963592c2c098e3746d811385456ce75890c Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 15 Aug 2019 01:58:41 -0300 Subject: [PATCH 09/15] Update mixin-filtering.js --- src/components/table/helpers/mixin-filtering.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/table/helpers/mixin-filtering.js b/src/components/table/helpers/mixin-filtering.js index fb011e1173e..b86c30f4759 100644 --- a/src/components/table/helpers/mixin-filtering.js +++ b/src/components/table/helpers/mixin-filtering.js @@ -94,6 +94,7 @@ export default { 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 @@ -107,11 +108,11 @@ export default { // If we have a debounce time, delay the update of this.localFilter this.filterTimer = setTimeout(() => { this.filterTimer = null - this.localFilter = this.filter + this.localFilter = this.filterSanitize(this.filter) }, timeout) } else { // Otherwise, immediately update this.localFilter - this.localFilter = this.filter + this.localFilter = this.filterSanitize(this.filter) } }, // Watch for changes to the filter criteria and filtered items vs localItems). @@ -146,11 +147,11 @@ export default { // Create non-reactive prop // Where we store the debounce timer id this.filterTimer = null - }, - created() { // If filter is "pre-set", set the criteria // This will trigger any watchers/dependants - this.localFilter = this.filter + this.localFilter = this.filterSanitize(this.filter) + }, + created() { // Set the initial filtered state. // In a nextTick so that we trigger a filtered event if needed this.$nextTick(() => { @@ -173,7 +174,8 @@ export default { !(isString(criteria) || isRegExp(criteria)) ) { // If using internal filter function, which only accepts string or RegExp - return '' + // return null to signify no filter + return null } // Could be a string, object or array, as needed by external filter function From 3cc090f7ac040c983169f28ce546a27554d8c978 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 15 Aug 2019 02:02:44 -0300 Subject: [PATCH 10/15] Update table-filtering.spec.js --- src/components/table/table-filtering.spec.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/components/table/table-filtering.spec.js b/src/components/table/table-filtering.spec.js index 85e6e47ca6b..ffd23d7326c 100644 --- a/src/components/table/table-filtering.spec.js +++ b/src/components/table/table-filtering.spec.js @@ -277,6 +277,25 @@ describe('table > filtering', () => { 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')[1][0]).toEqual([testItems[1]]) + wrapper.destroy() }) }) From e4d4f17e91bb3a39f4ca5ab82f68980bb1716440 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 15 Aug 2019 02:04:25 -0300 Subject: [PATCH 11/15] Update mixin-filtering.js --- src/components/table/helpers/mixin-filtering.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/components/table/helpers/mixin-filtering.js b/src/components/table/helpers/mixin-filtering.js index b86c30f4759..78c121efafb 100644 --- a/src/components/table/helpers/mixin-filtering.js +++ b/src/components/table/helpers/mixin-filtering.js @@ -143,15 +143,12 @@ export default { } } }, - beforeCreate() { - // Create non-reactive prop - // Where we store the debounce timer id + 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) - }, - created() { // Set the initial filtered state. // In a nextTick so that we trigger a filtered event if needed this.$nextTick(() => { From a62f51ff0e9ea49ee0dd3ab98299436e159dc1ee Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 15 Aug 2019 02:07:48 -0300 Subject: [PATCH 12/15] Update table-filtering.spec.js --- src/components/table/table-filtering.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/table/table-filtering.spec.js b/src/components/table/table-filtering.spec.js index ffd23d7326c..a895e01152d 100644 --- a/src/components/table/table-filtering.spec.js +++ b/src/components/table/table-filtering.spec.js @@ -294,7 +294,7 @@ describe('table > filtering', () => { expect(wrapper.vm.filterTimer).toBe(null) // Should immediately filter the items expect(wrapper.emitted('input').length).toBe(3) - expect(wrapper.emitted('input')[1][0]).toEqual([testItems[1]]) + expect(wrapper.emitted('input')[2][0]).toEqual([testItems[1]]) wrapper.destroy() }) From 7def610814a04e4e4ee5a420e01104bce62273c5 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 15 Aug 2019 02:42:20 -0300 Subject: [PATCH 13/15] Update README.md --- src/components/table/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/table/README.md b/src/components/table/README.md index 3ac4d34cb28..8d221056500 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** From 1f1260f51bcb8750084f6f017305672fb52da65f Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 15 Aug 2019 02:59:50 -0300 Subject: [PATCH 14/15] Update README.md --- src/components/table/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/table/README.md b/src/components/table/README.md index 8d221056500..210e2990f46 100644 --- a/src/components/table/README.md +++ b/src/components/table/README.md @@ -1986,9 +1986,9 @@ Setting the prop `filter` to null or an empty string will clear local items filt ### Debouncing filter criteria changes -If you have a text input tied to the `filter` prop of `` the filtering process will occur +If you have a text input tied to the `filter` prop of ``, 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 be sluggish. +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 From dcf8cabfbae2dff6c318b6e079b73796bfe47fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20M=C3=BCller?= Date: Fri, 16 Aug 2019 14:53:55 +0200 Subject: [PATCH 15/15] Update mixin-filtering.js --- src/components/table/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/table/README.md b/src/components/table/README.md index 210e2990f46..f90e28bd515 100644 --- a/src/components/table/README.md +++ b/src/components/table/README.md @@ -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 @@ -1996,7 +1996,7 @@ greater than `0` is provided, the filter will wait for that time before updating 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 sugested value of `filter-debounce` should be in the range of `100` to `200` +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 @@ -2194,7 +2194,7 @@ of records. - 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 minimze the number of calls to your back end API as the user types in the criteria. + will help minimize the number of calls to your back end API as the user types in the criteria. ### Force refreshing of table data