From 3ba243ce3b4ade48531ca323a12b465b5ad0b091 Mon Sep 17 00:00:00 2001 From: Ghislain B Date: Thu, 9 Jul 2020 17:35:47 -0400 Subject: [PATCH] feat(filter): refactor Filter Service by adding a debounce fn (#7) * feat(filter): refactor Filter Service by adding a debounce fn --- README.md | 1 - .../interfaces/slickEventData.interface.ts | 2 +- .../services/__tests__/filter.service.spec.ts | 22 ++++++++----- .../src/services/__tests__/utilities.spec.ts | 20 ++++++++++++ .../common/src/services/filter.service.ts | 31 ++++++++++++------- packages/common/src/services/utilities.ts | 16 ++++++++++ 6 files changed, 71 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 9d041696b..3c27d774b 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,6 @@ npm run test:watch - [x] Aggregators (6) - [x] Editors (11) - [x] Filters (17) - - [ ] Add optional debounce filter delay on local grid - [x] Formatters (31) - [ ] Extensions - [x] AutoTooltip diff --git a/packages/common/src/interfaces/slickEventData.interface.ts b/packages/common/src/interfaces/slickEventData.interface.ts index 2c4a457b3..8758c818c 100644 --- a/packages/common/src/interfaces/slickEventData.interface.ts +++ b/packages/common/src/interfaces/slickEventData.interface.ts @@ -1,4 +1,4 @@ -export interface SlickEventData extends Event { +export interface SlickEventData extends KeyboardEvent, MouseEvent, Event { /** Stops event from propagating up the DOM tree. */ stopPropagation: () => void; diff --git a/packages/common/src/services/__tests__/filter.service.spec.ts b/packages/common/src/services/__tests__/filter.service.spec.ts index c4b1f4112..b73e0dceb 100644 --- a/packages/common/src/services/__tests__/filter.service.spec.ts +++ b/packages/common/src/services/__tests__/filter.service.spec.ts @@ -296,9 +296,10 @@ describe('FilterService', () => { mockArgs = { grid: gridStub, column: mockColumn, node: document.getElementById(DOM_ELEMENT_ID) }; }); - it('should execute the callback normally when a keyup event is triggered and searchTerms are defined', () => { + it('should execute the search callback normally when a keyup event is triggered and searchTerms are defined', () => { const expectationColumnFilter = { columnDef: mockColumn, columnId: 'firstName', operator: 'EQ', searchTerms: ['John'] }; const spySearchChange = jest.spyOn(service.onSearchChange, 'notify'); + const spyEmit = jest.spyOn(service, 'emitFilterChanged'); service.init(gridStub); service.bindLocalOnFilter(gridStub, dataViewStub); @@ -316,6 +317,7 @@ describe('FilterService', () => { searchTerms: ['John'], grid: gridStub }, expect.anything()); + expect(spyEmit).toHaveBeenCalledWith('local'); }); it('should execute the callback normally when a keyup event is triggered and the searchTerm comes from this event.target', () => { @@ -473,7 +475,7 @@ describe('FilterService', () => { it('should execute the "onError" method when the Promise throws an error', (done) => { const errorExpected = 'promise error'; gridOptionMock.backendServiceApi.process = () => Promise.reject(errorExpected); - gridOptionMock.backendServiceApi.onError = (e) => jest.fn(); + gridOptionMock.backendServiceApi.onError = () => jest.fn(); const pubSubSpy = jest.spyOn(pubSubServiceStub, 'publish'); const spyOnError = jest.spyOn(gridOptionMock.backendServiceApi, 'onError'); jest.spyOn(gridOptionMock.backendServiceApi, 'process'); @@ -839,8 +841,9 @@ describe('FilterService', () => { expect(spy).toHaveBeenCalled(); }); - it('should execute "processOnFilterChanged" method when "shouldTriggerQuery" is set to True and "debounceTypingDelay" is bigger than 0', (done) => { - gridOptionMock.backendServiceApi.filterTypingDebounce = 1; + it('should execute "processOnFilterChanged" method when "shouldTriggerQuery" is set to True and "debounceTypingDelay" is bigger than 0', () => { + jest.useFakeTimers(); + gridOptionMock.backendServiceApi.filterTypingDebounce = 50; const spy = jest.spyOn(gridOptionMock.backendServiceApi.service, 'processOnFilterChanged').mockReturnValue('backend query'); service.init(gridStub); @@ -850,10 +853,13 @@ describe('FilterService', () => { // @ts-ignore service.onBackendFilterChange(mockEvent, { grid: gridStub, shouldTriggerQuery: true }); - setTimeout(() => { - expect(spy).toHaveBeenCalled(); - done(); - }, 1); + expect(spy).not.toHaveBeenCalled(); + + jest.runTimersToTime(49); + expect(spy).not.toHaveBeenCalled(); + + jest.runTimersToTime(50); + expect(spy).toHaveBeenCalled(); }); }); diff --git a/packages/common/src/services/__tests__/utilities.spec.ts b/packages/common/src/services/__tests__/utilities.spec.ts index ac734664a..08438a6a2 100644 --- a/packages/common/src/services/__tests__/utilities.spec.ts +++ b/packages/common/src/services/__tests__/utilities.spec.ts @@ -7,6 +7,7 @@ import { convertHierarchicalViewToParentChildArray, convertParentChildArrayToHierarchicalView, decimalFormatted, + debounce, deepCopy, findItemInHierarchicalStructure, findOrDefault, @@ -340,6 +341,25 @@ describe('Service/Utilies', () => { }); }); + describe('debounce method', () => { + it('should execute a function after a given waiting time', () => { + jest.useFakeTimers(); + + const func = jest.fn(); + const debouncedFunction = debounce(func, 100); + + debouncedFunction(); + expect(func).not.toBeCalled(); + + jest.runTimersToTime(50); + expect(func).not.toBeCalled(); + + jest.runTimersToTime(100); + expect(func).toBeCalled(); + expect(func.mock.calls.length).toBe(1); + }); + }); + describe('deepCopy method', () => { it('should return original input when it is not an object neither an array', () => { const msg = 'hello world'; diff --git a/packages/common/src/services/filter.service.ts b/packages/common/src/services/filter.service.ts index 5d85d7c77..e3ef42767 100644 --- a/packages/common/src/services/filter.service.ts +++ b/packages/common/src/services/filter.service.ts @@ -31,7 +31,7 @@ import { SlickNamespace, } from './../interfaces/index'; import { executeBackendCallback, refreshBackendDataset } from './backend-utilities'; -import { getDescendantProperty } from './utilities'; +import { debounce, getDescendantProperty } from './utilities'; import { PubSubService } from '../services/pubSub.service'; import { SharedService } from './shared.service'; @@ -39,8 +39,18 @@ import { SharedService } from './shared.service'; declare const Slick: SlickNamespace; // timer for keeping track of user typing waits -let timer: any; -const DEFAULT_FILTER_TYPING_DEBOUNCE = 500; +const DEFAULT_BACKEND_FILTER_TYPING_DEBOUNCE = 500; + +interface OnSearchChangeEvent { + clearFilterTriggered?: boolean; + shouldTriggerQuery?: boolean; + columnId: string | number; + columnDef: Column; + columnFilters: ColumnFilters; + operator: OperatorType | OperatorString | undefined; + searchTerms: any[] | undefined; + grid: SlickGrid; +} export class FilterService { private _eventHandler: SlickEventHandler; @@ -50,7 +60,7 @@ export class FilterService { private _columnFilters: ColumnFilters = {}; private _dataView: SlickDataView; private _grid: SlickGrid; - private _onSearchChange: SlickEvent; + private _onSearchChange: SlickEvent; private _tmpPreFilteredData: number[]; constructor(private filterFactory: FilterFactory, private pubSubService: PubSubService, private sharedService: SharedService) { @@ -176,7 +186,7 @@ export class FilterService { // bind any search filter change (e.g. input filter keyup event) const onSearchChangeHandler = this._onSearchChange; - (this._eventHandler as SlickEventHandler>).subscribe(this._onSearchChange, (e, args) => { + (this._eventHandler as SlickEventHandler>).subscribe(this._onSearchChange, (_e, args) => { const isGridWithTreeData = this._gridOptions?.enableTreeData ?? false; // When using Tree Data, we need to do it in 2 steps @@ -562,19 +572,18 @@ export class FilterService { const eventType = event && event.type; const eventKeyCode = event && event.keyCode; if (!isTriggeredByClearFilter && eventKeyCode !== KeyCode.ENTER && (eventType === 'input' || eventType === 'keyup' || eventType === 'keydown')) { - debounceTypingDelay = backendApi.hasOwnProperty('filterTypingDebounce') ? backendApi.filterTypingDebounce as number : DEFAULT_FILTER_TYPING_DEBOUNCE; + debounceTypingDelay = backendApi?.filterTypingDebounce ?? DEFAULT_BACKEND_FILTER_TYPING_DEBOUNCE; } // query backend, except when it's called by a ClearFilters then we won't if (args && args.shouldTriggerQuery) { // call the service to get a query back if (debounceTypingDelay > 0) { - clearTimeout(timer); - timer = setTimeout(() => { + debounce(() => { const query = backendApi.service.processOnFilterChanged(event, args); const totalItems = this._gridOptions && this._gridOptions.pagination && this._gridOptions.pagination.totalItems || 0; executeBackendCallback(backendApi, query, args, startTime, totalItems, this.emitFilterChanged.bind(this)); - }, debounceTypingDelay); + }, debounceTypingDelay)(); } else { const query = backendApi.service.processOnFilterChanged(event, args); const totalItems = this._gridOptions && this._gridOptions.pagination && this._gridOptions.pagination.totalItems || 0; @@ -655,7 +664,7 @@ export class FilterService { uiFilter.setValues(newFilter.searchTerms, newOperator); if (triggerOnSearchChangeEvent) { - this.callbackSearchEvent(null, { columnDef: uiFilter.columnDef, operator: newOperator, searchTerms: newFilter.searchTerms, shouldTriggerQuery: true }); + this.callbackSearchEvent(undefined, { columnDef: uiFilter.columnDef, operator: newOperator, searchTerms: newFilter.searchTerms, shouldTriggerQuery: true }); } } }); @@ -739,7 +748,7 @@ export class FilterService { * Callback method that is called and executed by the individual Filter (DOM element), * for example when user type in a word to search (which uses InputFilter), this Filter will execute the callback from a keyup event. */ - private callbackSearchEvent(event: any, args: FilterCallbackArg) { + private callbackSearchEvent(event: SlickEventData | undefined, args: FilterCallbackArg) { if (args) { const searchTerm = ((event && event.target) ? (event.target as HTMLInputElement).value : undefined); const searchTerms = (args.searchTerms && Array.isArray(args.searchTerms)) ? args.searchTerms : (searchTerm ? [searchTerm] : undefined); diff --git a/packages/common/src/services/utilities.ts b/packages/common/src/services/utilities.ts index ff6be44a5..ecb32667b 100644 --- a/packages/common/src/services/utilities.ts +++ b/packages/common/src/services/utilities.ts @@ -131,6 +131,22 @@ export function convertHierarchicalViewToParentChildArrayByReference(hi } } +/** + * Debounce create a new function g, which when called will delay the invocation of the original function f until n milliseconds after it was last called. + * @param func: function to debounce + * @param waitFor: waiting time in milliseconds + */ +export function debounce any | void)>(func: F, waitFor: number) { + const timeout = 0; + + const debounced = (...args: any) => { + clearTimeout(timeout); + setTimeout(() => func(...args), waitFor); + }; + + return debounced as (...args: Parameters) => ReturnType; +} + /** * Create an immutable clone of an array or object * (c) 2019 Chris Ferdinandi, MIT License, https://gomakethings.com