diff --git a/src/app/examples/grid-draggrouping.component.ts b/src/app/examples/grid-draggrouping.component.ts index 88adb79a1..d7795ea8e 100644 --- a/src/app/examples/grid-draggrouping.component.ts +++ b/src/app/examples/grid-draggrouping.component.ts @@ -215,6 +215,8 @@ export class GridDraggableGroupingComponent implements OnInit { showPreHeaderPanel: true, preHeaderPanelHeight: 40, enableFiltering: true, + // you could debounce/throttle the input text filter if you have lots of data + // filterTypingDebounce: 250, enableSorting: true, exportOptions: { sanitizeDataExport: true diff --git a/src/app/examples/grid-grouping.component.ts b/src/app/examples/grid-grouping.component.ts index 5513ef845..f3f6f86d1 100644 --- a/src/app/examples/grid-grouping.component.ts +++ b/src/app/examples/grid-grouping.component.ts @@ -136,6 +136,8 @@ export class GridGroupingComponent implements OnInit { }, enableExcelExport: true, enableFiltering: true, + // you could debounce/throttle the input text filter if you have lots of data + // filterTypingDebounce: 250, enableGrouping: true, exportOptions: { sanitizeDataExport: true diff --git a/src/app/modules/angular-slickgrid/components/angular-slickgrid.component.ts b/src/app/modules/angular-slickgrid/components/angular-slickgrid.component.ts index 94b834b35..285533494 100644 --- a/src/app/modules/angular-slickgrid/components/angular-slickgrid.component.ts +++ b/src/app/modules/angular-slickgrid/components/angular-slickgrid.component.ts @@ -769,7 +769,7 @@ export class AngularSlickgridComponent implements AfterViewInit, OnDestroy, OnIn private executeAfterDataviewCreated(grid: any, gridOptions: GridOption, dataView: any) { // if user entered some Sort "presets", we need to reflect them all in the DOM if (gridOptions.enableSorting) { - if (gridOptions.presets && Array.isArray(gridOptions.presets.sorters) && gridOptions.presets.sorters.length > 0) { + if (gridOptions.presets && Array.isArray(gridOptions.presets.sorters)) { this.sortService.loadGridSorters(gridOptions.presets.sorters); } } @@ -1018,7 +1018,7 @@ export class AngularSlickgridComponent implements AfterViewInit, OnDestroy, OnIn private loadPresetsWhenDatasetInitialized() { if (this.gridOptions && !this.customDataView) { // if user entered some Filter "presets", we need to reflect them all in the DOM - if (this.gridOptions.presets && Array.isArray(this.gridOptions.presets.filters) && this.gridOptions.presets.filters.length > 0) { + if (this.gridOptions.presets && Array.isArray(this.gridOptions.presets.filters)) { this.filterService.populateColumnFilterSearchTermPresets(this.gridOptions.presets.filters); } diff --git a/src/app/modules/angular-slickgrid/filters/__tests__/compoundInputFilter.spec.ts b/src/app/modules/angular-slickgrid/filters/__tests__/compoundInputFilter.spec.ts index b6757dec8..17c33332c 100644 --- a/src/app/modules/angular-slickgrid/filters/__tests__/compoundInputFilter.spec.ts +++ b/src/app/modules/angular-slickgrid/filters/__tests__/compoundInputFilter.spec.ts @@ -1,7 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { Column, FilterArguments, FieldType, GridOption, OperatorType } from '../../models'; +import { BackendServiceApi, Column, FilterArguments, FieldType, GridOption, OperatorType, } from '../../models'; import { Filters } from '..'; import { CompoundInputFilter } from '../compoundInputFilter'; @@ -119,7 +119,7 @@ describe('CompoundInputFilter', () => { const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input') as HTMLInputElement; filterInputElm.focus(); - filterInputElm.dispatchEvent(new (window.window as any).Event('input', { keyCode: 97, bubbles: true, cancelable: true })); + filterInputElm.dispatchEvent(new (window.window as any).Event('keyup', { keyCode: 97, bubbles: true, cancelable: true })); const filterFilledElms = divContainer.querySelectorAll('.search-filter.filter-duration.filled'); expect(filterFilledElms.length).toBe(1); @@ -143,23 +143,6 @@ describe('CompoundInputFilter', () => { expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '', searchTerms: ['abc'], shouldTriggerQuery: true }); }); - it('should call "setValues" and expect that value NOT to be in the callback when triggered by a keyup event that is NOT the ENTER key', () => { - const spyCallback = jest.spyOn(filterArguments, 'callback'); - - filter.init(filterArguments); - filter.setValues(['abc']); - const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input') as HTMLInputElement; - - filterInputElm.focus(); - const event = new (window.window as any).Event('keyup', { bubbles: true, cancelable: true }); - event.key = 'a'; - filterInputElm.dispatchEvent(event); - const filterFilledElms = divContainer.querySelectorAll('.search-filter.filter-duration.filled'); - - expect(filterFilledElms.length).toBe(0); - expect(spyCallback).not.toHaveBeenCalled(); - }); - it('should call "setValues" with "operator" set in the filter arguments and expect that value to be in the callback when triggered', () => { mockColumn.type = FieldType.number; const filterArgs = { ...filterArguments, operator: '>' } as FilterArguments; @@ -170,7 +153,7 @@ describe('CompoundInputFilter', () => { const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input') as HTMLInputElement; filterInputElm.focus(); - filterInputElm.dispatchEvent(new (window.window as any).Event('input', { keyCode: 97, bubbles: true, cancelable: true })); + filterInputElm.dispatchEvent(new (window.window as any).Event('keyup', { keyCode: 97, bubbles: true, cancelable: true })); expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '>', searchTerms: ['9'], shouldTriggerQuery: true }); }); @@ -215,7 +198,7 @@ describe('CompoundInputFilter', () => { const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input') as HTMLInputElement; filterInputElm.focus(); - filterInputElm.dispatchEvent(new (window.window as any).Event('input', { keyCode: 97, bubbles: true, cancelable: true })); + filterInputElm.dispatchEvent(new (window.window as any).Event('keyup', { keyCode: 97, bubbles: true, cancelable: true })); expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '>', searchTerms: ['987'], shouldTriggerQuery: true }); }); @@ -232,7 +215,7 @@ describe('CompoundInputFilter', () => { const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input') as HTMLInputElement; filterInputElm.focus(); - filterInputElm.dispatchEvent(new (window.window as any).Event('input', { keyCode: 97, bubbles: true, cancelable: true })); + filterInputElm.dispatchEvent(new (window.window as any).Event('keyup', { keyCode: 97, bubbles: true, cancelable: true })); expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '>', searchTerms: ['987'], shouldTriggerQuery: true }); }); @@ -245,11 +228,49 @@ describe('CompoundInputFilter', () => { filterInputElm.focus(); filterInputElm.value = 'a'; - filterInputElm.dispatchEvent(new (window.window as any).Event('input', { keyCode: 97, bubbles: true, cancelable: true })); + filterInputElm.dispatchEvent(new (window.window as any).Event('keyup', { keyCode: 97, bubbles: true, cancelable: true })); expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '', searchTerms: ['a'], shouldTriggerQuery: true }); }); + it('should trigger the callback method with a delay when "filterTypingDebounce" is set in grid options and user types something in the input', (done) => { + const spyCallback = jest.spyOn(filterArguments, 'callback'); + gridOptionMock.filterTypingDebounce = 2; + + filter.init(filterArguments); + const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input') as HTMLInputElement; + + filterInputElm.focus(); + filterInputElm.value = 'a'; + filterInputElm.dispatchEvent(new (window.window as any).Event('keyup', { key: 'a', keyCode: 97, bubbles: true, cancelable: true })); + + setTimeout(() => { + expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '', searchTerms: ['a'], shouldTriggerQuery: true }); + done(); + }, 2); + }); + + it('should trigger the callback method with a delay when BackendService is used with a "filterTypingDebounce" is set in grid options and user types something in the input', (done) => { + const spyCallback = jest.spyOn(filterArguments, 'callback'); + gridOptionMock.defaultBackendServiceFilterTypingDebounce = 2; + gridOptionMock.backendServiceApi = { + filterTypingDebounce: 2, + service: {} + } as unknown as BackendServiceApi; + + filter.init(filterArguments); + const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input') as HTMLInputElement; + + filterInputElm.focus(); + filterInputElm.value = 'a'; + filterInputElm.dispatchEvent(new (window.window as any).Event('keyup', { key: 'a', keyCode: 97, bubbles: true, cancelable: true })); + + setTimeout(() => { + expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '', searchTerms: ['a'], shouldTriggerQuery: true }); + done(); + }, 2); + }); + it('should create the input filter with a default search term when passed as a filter argument', () => { filterArguments.searchTerms = ['xyz']; diff --git a/src/app/modules/angular-slickgrid/filters/__tests__/inputFilter.spec.ts b/src/app/modules/angular-slickgrid/filters/__tests__/inputFilter.spec.ts index b0a637b24..3791698ae 100644 --- a/src/app/modules/angular-slickgrid/filters/__tests__/inputFilter.spec.ts +++ b/src/app/modules/angular-slickgrid/filters/__tests__/inputFilter.spec.ts @@ -1,5 +1,5 @@ import { InputFilter } from '../inputFilter'; -import { GridOption, FilterArguments, Column } from '../../models'; +import { BackendServiceApi, Column, FilterArguments, GridOption, } from '../../models'; import { Filters } from '..'; const containerId = 'demo-container'; @@ -78,7 +78,7 @@ describe('InputFilter', () => { const filterElm = divContainer.querySelector('input.filter-duration') as HTMLInputElement; filterElm.focus(); - filterElm.dispatchEvent(new (window.window as any).Event('input', { keyCode: 97, bubbles: true, cancelable: true })); + filterElm.dispatchEvent(new (window.window as any).Event('keyup', { keyCode: 97, bubbles: true, cancelable: true })); const filterFilledElms = divContainer.querySelectorAll('input.filter-duration.filled'); expect(filterFilledElms.length).toBe(1); @@ -102,23 +102,6 @@ describe('InputFilter', () => { expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '', searchTerms: ['abc'], shouldTriggerQuery: true }); }); - it('should call "setValues" and expect that value NOT to be in the callback when triggered by a keyup event that is NOT the ENTER key', () => { - const spyCallback = jest.spyOn(filterArguments, 'callback'); - - filter.init(filterArguments); - filter.setValues('abc'); - const filterElm = divContainer.querySelector('input.filter-duration') as HTMLInputElement; - - filterElm.focus(); - const event = new (window.window as any).Event('keyup', { bubbles: true, cancelable: true }); - event.key = 'a'; - filterElm.dispatchEvent(event); - const filterFilledElms = divContainer.querySelectorAll('input.filter-duration.filled'); - - expect(filterFilledElms.length).toBe(0); - expect(spyCallback).not.toHaveBeenCalled(); - }); - it('should call "setValues" an operator and with extra spaces at the beginning of the searchTerms and trim value when "enableFilterTrimWhiteSpace" is enabled in grid options', () => { gridOptionMock.enableFilterTrimWhiteSpace = true; const spyCallback = jest.spyOn(filterArguments, 'callback'); @@ -128,7 +111,7 @@ describe('InputFilter', () => { const filterElm = divContainer.querySelector('input.filter-duration') as HTMLInputElement; filterElm.focus(); - filterElm.dispatchEvent(new (window.window as any).Event('input', { keyCode: 97, bubbles: true, cancelable: true })); + filterElm.dispatchEvent(new (window.window as any).Event('keyup', { keyCode: 97, bubbles: true, cancelable: true })); const filterFilledElms = divContainer.querySelectorAll('input.filter-duration.filled'); expect(filterFilledElms.length).toBe(1); @@ -145,7 +128,7 @@ describe('InputFilter', () => { const filterElm = divContainer.querySelector('input.filter-duration') as HTMLInputElement; filterElm.focus(); - filterElm.dispatchEvent(new (window.window as any).Event('input', { keyCode: 97, bubbles: true, cancelable: true })); + filterElm.dispatchEvent(new (window.window as any).Event('keyup', { keyCode: 97, bubbles: true, cancelable: true })); const filterFilledElms = divContainer.querySelectorAll('input.filter-duration.filled'); expect(filterFilledElms.length).toBe(1); @@ -201,11 +184,48 @@ describe('InputFilter', () => { filterElm.focus(); filterElm.value = 'a'; - filterElm.dispatchEvent(new (window.window as any).Event('input', { keyCode: 97, bubbles: true, cancelable: true })); + filterElm.dispatchEvent(new (window.window as any).Event('keyup', { keyCode: 97, bubbles: true, cancelable: true })); expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: 'EQ', searchTerms: ['a'], shouldTriggerQuery: true }); }); + it('should trigger the callback method with a delay when "filterTypingDebounce" is set in grid options and user types something in the input', (done) => { + const spyCallback = jest.spyOn(filterArguments, 'callback'); + gridOptionMock.filterTypingDebounce = 2; + + filter.init(filterArguments); + const filterElm = divContainer.querySelector('input.filter-duration') as HTMLInputElement; + + filterElm.focus(); + filterElm.value = 'a'; + filterElm.dispatchEvent(new (window.window as any).Event('keyup', { key: 'a', keyCode: 97, bubbles: true, cancelable: true })); + + setTimeout(() => { + expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: 'EQ', searchTerms: ['a'], shouldTriggerQuery: true }); + done(); + }, 2); + }); + + it('should trigger the callback method with a delay when BackendService is used with a "filterTypingDebounce" is set in grid options and user types something in the input', (done) => { + const spyCallback = jest.spyOn(filterArguments, 'callback'); + gridOptionMock.defaultBackendServiceFilterTypingDebounce = 2; + gridOptionMock.backendServiceApi = { + service: {} + } as unknown as BackendServiceApi; + + filter.init(filterArguments); + const filterElm = divContainer.querySelector('input.filter-duration') as HTMLInputElement; + + filterElm.focus(); + filterElm.value = 'a'; + filterElm.dispatchEvent(new (window.window as any).Event('keyup', { key: 'a', keyCode: 97, bubbles: true, cancelable: true })); + + setTimeout(() => { + expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: 'EQ', searchTerms: ['a'], shouldTriggerQuery: true }); + done(); + }, 2); + }); + it('should create the input filter with a default search term when passed as a filter argument', () => { filterArguments.searchTerms = ['xyz']; diff --git a/src/app/modules/angular-slickgrid/filters/__tests__/inputMaskFilter.spec.ts b/src/app/modules/angular-slickgrid/filters/__tests__/inputMaskFilter.spec.ts index 2ba48f3a1..457f32c39 100644 --- a/src/app/modules/angular-slickgrid/filters/__tests__/inputMaskFilter.spec.ts +++ b/src/app/modules/angular-slickgrid/filters/__tests__/inputMaskFilter.spec.ts @@ -22,7 +22,7 @@ describe('InputMaskFilter', () => { let divContainer: HTMLDivElement; let filter: InputMaskFilter; let filterArguments: FilterArguments; - let spyGetHeaderRow; + let spyGetHeaderRow: any; let mockColumn: Column; beforeEach(() => { @@ -46,7 +46,7 @@ describe('InputMaskFilter', () => { }); it('should throw an error when trying to call init without any arguments', () => { - expect(() => filter.init(null)).toThrowError('[Angular-SlickGrid] A filter must always have an "init()" with valid arguments.'); + expect(() => filter.init(null as any)).toThrowError('[Angular-SlickGrid] A filter must always have an "init()" with valid arguments.'); }); it('should throw an error when no mask provided in params', () => { @@ -54,7 +54,7 @@ describe('InputMaskFilter', () => { }); it('should initialize the filter and expect an input of type text', () => { - mockColumn.filter.params = { mask: '000-000-0000' }; + mockColumn.filter!.params = { mask: '000-000-0000' }; filter.init(filterArguments); const filterCount = divContainer.querySelectorAll('input.filter-mask').length; @@ -76,164 +76,164 @@ describe('InputMaskFilter', () => { }); it('should call "setValues" and expect that value to be in the callback when triggered', () => { - mockColumn.filter.params = { mask: '000-000-0000' }; + mockColumn.filter!.params = { mask: '000-000-0000' }; const spyCallback = jest.spyOn(filterArguments, 'callback'); filter.init(filterArguments); filter.setValues('1234567890'); - const filterElm = divContainer.querySelector('input.filter-mask'); + const filterElm = divContainer.querySelector('input.filter-mask') as HTMLInputElement; filterElm.focus(); - filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('input', { keyCode: 97, bubbles: true, cancelable: true })); + filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('keyup', { keyCode: 97, bubbles: true, cancelable: true })); expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '', searchTerms: ['1234567890'], shouldTriggerQuery: true }); }); it('should call "setValues" an operator and with 10 digits and expect input value to be formatted as a phone as the mask format specifies', () => { - mockColumn.filter.params = { mask: '(000) 000-0000' }; + mockColumn.filter!.params = { mask: '(000) 000-0000' }; const spyCallback = jest.spyOn(filterArguments, 'callback'); filter.init(filterArguments); filter.setValues('1234567890', 'EQ'); - const filterElm = divContainer.querySelector('input.filter-mask'); + const filterElm = divContainer.querySelector('input.filter-mask') as HTMLInputElement; filterElm.focus(); - filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('input', { keyCode: 97, bubbles: true, cancelable: true })); + filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('keyup', { keyCode: 97, bubbles: true, cancelable: true })); expect(filterElm.value).toBe('(123) 456-7890'); expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: 'EQ', searchTerms: ['1234567890'], shouldTriggerQuery: true }); }); it('should call "setValues" with 10 digits and other extra characters but still expect the value to be formatted as a phone as the mask format specifies', () => { - mockColumn.filter.params = { mask: '(000) 000-0000' }; + mockColumn.filter!.params = { mask: '(000) 000-0000' }; const spyCallback = jest.spyOn(filterArguments, 'callback'); filter.init(filterArguments); filter.setValues('1234567890abc'); - const filterElm = divContainer.querySelector('input.filter-mask'); + const filterElm = divContainer.querySelector('input.filter-mask') as HTMLInputElement; filterElm.focus(); - filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('input', { keyCode: 97, bubbles: true, cancelable: true })); + filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('keyup', { keyCode: 97, bubbles: true, cancelable: true })); expect(filterElm.value).toBe('(123) 456-7890'); expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '', searchTerms: ['1234567890'], shouldTriggerQuery: true }); }); it('should call "setValues" with 10 digits and expect it to work with using 9 instead of 0 in the mask', () => { - mockColumn.filter.params = { mask: '(999) 999-9999' }; + mockColumn.filter!.params = { mask: '(999) 999-9999' }; const spyCallback = jest.spyOn(filterArguments, 'callback'); filter.init(filterArguments); filter.setValues('1234567890'); - const filterElm = divContainer.querySelector('input.filter-mask'); + const filterElm = divContainer.querySelector('input.filter-mask') as HTMLInputElement; filterElm.focus(); - filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('input', { keyCode: 97, bubbles: true, cancelable: true })); + filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('keyup', { keyCode: 97, bubbles: true, cancelable: true })); expect(filterElm.value).toBe('(123) 456-7890'); expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '', searchTerms: ['1234567890'], shouldTriggerQuery: true }); }); it('should call "setValues" with a characters & numbers mask (e.g. postal code) and expect it to returned a formatted string', () => { - mockColumn.filter.params = { mask: 'A0A 0A0' }; + mockColumn.filter!.params = { mask: 'A0A 0A0' }; const spyCallback = jest.spyOn(filterArguments, 'callback'); filter.init(filterArguments); filter.setValues('H1H1H1'); - const filterElm = divContainer.querySelector('input.filter-mask'); + const filterElm = divContainer.querySelector('input.filter-mask') as HTMLInputElement; filterElm.focus(); - filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('input', { keyCode: 97, bubbles: true, cancelable: true })); + filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('keyup', { keyCode: 97, bubbles: true, cancelable: true })); expect(filterElm.value).toBe('H1H 1H1'); expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '', searchTerms: ['H1H1H1'], shouldTriggerQuery: true }); }); it('should call "setValues" with 10 digits and expect it to work even if input as extra spaces at the beginning when "enableFilterTrimWhiteSpace" is enabled in grid options', () => { - mockColumn.filter.params = { mask: '(999) 999-9999' }; + mockColumn.filter!.params = { mask: '(999) 999-9999' }; gridOptionMock.enableFilterTrimWhiteSpace = true; const spyCallback = jest.spyOn(filterArguments, 'callback'); filter.init(filterArguments); filter.setValues(' 1234567890 '); - const filterElm = divContainer.querySelector('input.filter-mask'); + const filterElm = divContainer.querySelector('input.filter-mask') as HTMLInputElement; filterElm.focus(); - filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('input', { keyCode: 97, bubbles: true, cancelable: true })); + filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('keyup', { keyCode: 97, bubbles: true, cancelable: true })); expect(filterElm.value).toBe('(123) 456-7890'); expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '', searchTerms: ['1234567890'], shouldTriggerQuery: true }); }); it('should call "setValues" with 10 digits and expect it to work even if input as extra spaces at the beginning when "enableTrimWhiteSpace" is enabled in the column filter', () => { - mockColumn.filter.params = { mask: '(999) 999-9999' }; + mockColumn.filter!.params = { mask: '(999) 999-9999' }; gridOptionMock.enableFilterTrimWhiteSpace = false; - mockColumn.filter.enableTrimWhiteSpace = true; + mockColumn.filter!.enableTrimWhiteSpace = true; const spyCallback = jest.spyOn(filterArguments, 'callback'); filter.init(filterArguments); filter.setValues(' 1234567890 '); - const filterElm = divContainer.querySelector('input.filter-mask'); + const filterElm = divContainer.querySelector('input.filter-mask') as HTMLInputElement; filterElm.focus(); - filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('input', { keyCode: 97, bubbles: true, cancelable: true })); + filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('keyup', { keyCode: 97, bubbles: true, cancelable: true })); expect(filterElm.value).toBe('(123) 456-7890'); expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '', searchTerms: ['1234567890'], shouldTriggerQuery: true }); }); it('should call "setValues" all invalid characters and expect an empty shell as it does not match the mask', () => { - mockColumn.filter.params = { mask: '(000) 000-0000' }; + mockColumn.filter!.params = { mask: '(000) 000-0000' }; const spyCallback = jest.spyOn(filterArguments, 'callback'); filter.init(filterArguments); filter.setValues('abc'); - const filterElm = divContainer.querySelector('input.filter-mask'); + const filterElm = divContainer.querySelector('input.filter-mask') as HTMLInputElement; filterElm.focus(); - filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('input', { keyCode: 97, bubbles: true, cancelable: true })); + filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('keyup', { keyCode: 97, bubbles: true, cancelable: true })); expect(filterElm.value).toBe('() -'); expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '', searchTerms: [''], shouldTriggerQuery: true }); }); it('should trigger the callback method when user types something in the input', () => { - mockColumn.filter.params = { mask: '(000) 000-0000' }; + mockColumn.filter!.params = { mask: '(000) 000-0000' }; const spyCallback = jest.spyOn(filterArguments, 'callback'); filter.init(filterArguments); - const filterElm = divContainer.querySelector('input.filter-mask'); + const filterElm = divContainer.querySelector('input.filter-mask') as HTMLInputElement; filterElm.focus(); filterElm.value = '1'; - filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('input', { keyCode: 97, bubbles: true, cancelable: true })); + filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('keyup', { keyCode: 97, bubbles: true, cancelable: true })); expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: 'EQ', searchTerms: ['1'], shouldTriggerQuery: true }); }); it('should create the input filter with a default search term when passed as a filter argument', () => { - mockColumn.filter.params = { mask: '(000) 000-0000' }; + mockColumn.filter!.params = { mask: '(000) 000-0000' }; filterArguments.searchTerms = ['123']; filter.init(filterArguments); - const filterElm = divContainer.querySelector('input.filter-mask'); + const filterElm = divContainer.querySelector('input.filter-mask') as HTMLInputElement; expect(filterElm.value).toBe('123'); }); it('should trigger a callback with the clear filter set when calling the "clear" method', () => { - mockColumn.filter.params = { mask: '(000) 000-0000' }; + mockColumn.filter!.params = { mask: '(000) 000-0000' }; const spyCallback = jest.spyOn(filterArguments, 'callback'); filterArguments.searchTerms = ['123']; filter.init(filterArguments); filter.clear(); - const filterElm = divContainer.querySelector('input.filter-mask'); + const filterElm = divContainer.querySelector('input.filter-mask') as HTMLInputElement; expect(filterElm.value).toBe(''); expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, clearFilterTriggered: true, shouldTriggerQuery: true }); }); it('should trigger a callback with the clear filter but without querying when when calling the "clear" method with False as argument', () => { - mockColumn.filter.params = { mask: '(000) 000-0000' }; + mockColumn.filter!.params = { mask: '(000) 000-0000' }; const spyCallback = jest.spyOn(filterArguments, 'callback'); filterArguments.searchTerms = ['123']; filter.init(filterArguments); filter.clear(false); - const filterElm = divContainer.querySelector('input.filter-mask'); + const filterElm = divContainer.querySelector('input.filter-mask') as HTMLInputElement; expect(filterElm.value).toBe(''); expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, clearFilterTriggered: true, shouldTriggerQuery: false }); diff --git a/src/app/modules/angular-slickgrid/filters/__tests__/inputNumberFilter.spec.ts b/src/app/modules/angular-slickgrid/filters/__tests__/inputNumberFilter.spec.ts index 5cbff3fbd..e434931e2 100644 --- a/src/app/modules/angular-slickgrid/filters/__tests__/inputNumberFilter.spec.ts +++ b/src/app/modules/angular-slickgrid/filters/__tests__/inputNumberFilter.spec.ts @@ -23,7 +23,7 @@ describe('InputNumberFilter', () => { let divContainer: HTMLDivElement; let filter: InputNumberFilter; let filterArguments: FilterArguments; - let spyGetHeaderRow; + let spyGetHeaderRow: any; let mockColumn: Column; beforeEach(() => { @@ -47,7 +47,7 @@ describe('InputNumberFilter', () => { }); it('should throw an error when trying to call init without any arguments', () => { - expect(() => filter.init(null)).toThrowError('[Angular-SlickGrid] A filter must always have an "init()" with valid arguments.'); + expect(() => filter.init(null as any)).toThrowError('[Angular-SlickGrid] A filter must always have an "init()" with valid arguments.'); }); it('should initialize the filter and expect an input of type number', () => { diff --git a/src/app/modules/angular-slickgrid/filters/__tests__/inputPasswordFilter.spec.ts b/src/app/modules/angular-slickgrid/filters/__tests__/inputPasswordFilter.spec.ts index 5b2f33a85..dce276171 100644 --- a/src/app/modules/angular-slickgrid/filters/__tests__/inputPasswordFilter.spec.ts +++ b/src/app/modules/angular-slickgrid/filters/__tests__/inputPasswordFilter.spec.ts @@ -23,7 +23,7 @@ describe('InputPasswordFilter', () => { let divContainer: HTMLDivElement; let filter: InputPasswordFilter; let filterArguments: FilterArguments; - let spyGetHeaderRow; + let spyGetHeaderRow: any; let mockColumn: Column; beforeEach(() => { @@ -47,7 +47,7 @@ describe('InputPasswordFilter', () => { }); it('should throw an error when trying to call init without any arguments', () => { - expect(() => filter.init(null)).toThrowError('[Angular-SlickGrid] A filter must always have an "init()" with valid arguments.'); + expect(() => filter.init(null as any)).toThrowError('[Angular-SlickGrid] A filter must always have an "init()" with valid arguments.'); }); it('should initialize the filter and expect an input of type password', () => { diff --git a/src/app/modules/angular-slickgrid/filters/compoundInputFilter.ts b/src/app/modules/angular-slickgrid/filters/compoundInputFilter.ts index 138e82538..dbe4e6c04 100644 --- a/src/app/modules/angular-slickgrid/filters/compoundInputFilter.ts +++ b/src/app/modules/angular-slickgrid/filters/compoundInputFilter.ts @@ -22,6 +22,7 @@ declare const $: any; export class CompoundInputFilter implements Filter { protected _clearFilterTriggered = false; + protected _debounceTypingDelay = 0; protected _shouldTriggerQuery = true; protected _inputType = 'text'; protected $filterElm: any; @@ -32,6 +33,7 @@ export class CompoundInputFilter implements Filter { searchTerms: SearchTerm[] = []; columnDef!: Column; callback!: FilterCallback; + timer?: any; constructor(protected readonly translate: TranslateService) { } @@ -92,13 +94,19 @@ export class CompoundInputFilter implements Filter { // filter input can only have 1 search term, so we will use the 1st array index if it exist const searchTerm = (Array.isArray(this.searchTerms) && this.searchTerms.length >= 0) ? this.searchTerms[0] : ''; + // analyze if we have any keyboard debounce delay (do we wait for user to finish typing before querying) + // it is used by default for a backend service but is optional when using local dataset + const backendApi = this.gridOptions?.backendServiceApi; + this._debounceTypingDelay = (backendApi ? (backendApi?.filterTypingDebounce ?? this.gridOptions?.defaultBackendServiceFilterTypingDebounce) : this.gridOptions?.filterTypingDebounce) ?? 0; + // step 1, create the DOM Element of the filter which contain the compound Operator+Input // and initialize it if searchTerms is filled this.$filterElm = this.createDomElement(searchTerm); // step 3, subscribe to the input change event and run the callback when that happens // also add/remove "filled" class for styling purposes - this.$filterInputElm.on('keyup input', this.onTriggerEvent.bind(this)); + // we'll use all necessary events to cover the following (keyup, change, mousewheel & spinner) + this.$filterInputElm.on('keyup blur change wheel', this.onTriggerEvent.bind(this)); this.$selectOperatorElm.on('change', this.onTriggerEvent.bind(this)); } @@ -121,7 +129,7 @@ export class CompoundInputFilter implements Filter { */ destroy() { if (this.$filterElm && this.$selectOperatorElm) { - this.$filterElm.off('keyup input').remove(); + this.$filterElm.off('keyup blur change wheel').remove(); this.$selectOperatorElm.off('change'); } this.$filterElm = null; @@ -250,26 +258,35 @@ export class CompoundInputFilter implements Filter { return $filterContainerElm; } - protected onTriggerEvent(e: KeyboardEvent | undefined) { - // we'll use the "input" event for everything (keyup, change, mousewheel & spinner) - // with 1 small exception, we need to use the keyup event to handle ENTER key, everything will be processed by the "input" event - if (e && e.type === 'keyup' && e.key !== 'Enter') { - return; - } + /** + * Event trigger, could be called by the Operator dropdown or the input itself and we will cover the following (keyup, change, mousewheel & spinner) + * We will trigger the Filter Service callback from this handler + */ + protected onTriggerEvent(event: KeyboardEvent | undefined) { if (this._clearFilterTriggered) { - this.callback(e, { columnDef: this.columnDef, clearFilterTriggered: this._clearFilterTriggered, shouldTriggerQuery: this._shouldTriggerQuery }); + this.callback(event, { columnDef: this.columnDef, clearFilterTriggered: this._clearFilterTriggered, shouldTriggerQuery: this._shouldTriggerQuery }); this.$filterElm.removeClass('filled'); } else { + const eventType = event?.type ?? ''; const selectedOperator = this.$selectOperatorElm.find('option:selected').val(); - let value = this.$filterInputElm.val(); + let value = this.$filterInputElm.val() as string; const enableWhiteSpaceTrim = this.gridOptions.enableFilterTrimWhiteSpace || this.columnFilter.enableTrimWhiteSpace; if (typeof value === 'string' && enableWhiteSpaceTrim) { value = value.trim(); } (value !== null && value !== undefined && value !== '') ? this.$filterElm.addClass('filled') : this.$filterElm.removeClass('filled'); - this.callback(e, { columnDef: this.columnDef, searchTerms: (value ? [value] : null), operator: selectedOperator || '', shouldTriggerQuery: this._shouldTriggerQuery }); + const callbackArgs = { columnDef: this.columnDef, searchTerms: (value ? [value] : null), operator: selectedOperator || '', shouldTriggerQuery: this._shouldTriggerQuery }; + const typingDelay = (eventType === 'keyup' && event?.key !== 'Enter') ? this._debounceTypingDelay : 0; + + if (typingDelay > 0) { + clearTimeout(this.timer); + this.timer = setTimeout(() => this.callback(event, callbackArgs), typingDelay); + } else { + this.callback(event, callbackArgs); + } } + // reset both flags for next use this._clearFilterTriggered = false; this._shouldTriggerQuery = true; diff --git a/src/app/modules/angular-slickgrid/filters/inputFilter.ts b/src/app/modules/angular-slickgrid/filters/inputFilter.ts index 9c83affba..0fb2d5f6e 100644 --- a/src/app/modules/angular-slickgrid/filters/inputFilter.ts +++ b/src/app/modules/angular-slickgrid/filters/inputFilter.ts @@ -15,8 +15,10 @@ declare const $: any; export class InputFilter implements Filter { protected _clearFilterTriggered = false; + protected _debounceTypingDelay = 0; protected _shouldTriggerQuery = true; protected _inputType = 'text'; + protected _timer?: any; protected $filterElm: any; grid: any; searchTerms: SearchTerm[] = []; @@ -74,6 +76,11 @@ export class InputFilter implements Filter { this.columnDef = args.columnDef; this.searchTerms = (args.hasOwnProperty('searchTerms') ? args.searchTerms : []) || []; + // analyze if we have any keyboard debounce delay (do we wait for user to finish typing before querying) + // it is used by default for a backend service but is optional when using local dataset + const backendApi = this.gridOptions?.backendServiceApi; + this._debounceTypingDelay = (backendApi ? (backendApi?.filterTypingDebounce ?? this.gridOptions?.defaultBackendServiceFilterTypingDebounce) : this.gridOptions?.filterTypingDebounce) ?? 0; + // filter input can only have 1 search term, so we will use the 1st array index if it exist const searchTerm = (Array.isArray(this.searchTerms) && this.searchTerms.length >= 0) ? this.searchTerms[0] : ''; @@ -85,7 +92,8 @@ export class InputFilter implements Filter { // step 3, subscribe to the input change event and run the callback when that happens // also add/remove "filled" class for styling purposes - this.$filterElm.on('keyup input', this.handleInputChange.bind(this)); + // we'll use all necessary events to cover the following (keyup, change, mousewheel & spinner) + this.$filterElm.on('keyup blur change wheel', this.handleInputChange.bind(this)); } /** @@ -97,7 +105,7 @@ export class InputFilter implements Filter { this._shouldTriggerQuery = shouldTriggerQuery; this.searchTerms = []; this.$filterElm.val(''); - this.$filterElm.trigger('input'); + this.$filterElm.trigger('change'); } } @@ -106,7 +114,7 @@ export class InputFilter implements Filter { */ destroy() { if (this.$filterElm) { - this.$filterElm.off('keyup input').remove(); + this.$filterElm.off('keyup blur change wheel').remove(); } this.$filterElm = null; } @@ -213,25 +221,33 @@ export class InputFilter implements Filter { return $filterElm; } - protected handleInputChange(e: any) { - // we'll use the "input" event for everything (keyup, change, mousewheel & spinner) - // with 1 small exception, we need to use the keyup event to handle ENTER key, everything will be processed by the "input" event - if (e && e.type === 'keyup' && e.key !== 'Enter') { - return; - } - let value = e && e.target && e.target.value || ''; - const enableWhiteSpaceTrim = this.gridOptions.enableFilterTrimWhiteSpace || this.columnFilter.enableTrimWhiteSpace; - if (typeof value === 'string' && enableWhiteSpaceTrim) { - value = value.trim(); - } - + /** + * Event handler to cover the following (keyup, change, mousewheel & spinner) + * We will trigger the Filter Service callback from this handler + */ + protected handleInputChange(event: KeyboardEvent & { target: any; }) { if (this._clearFilterTriggered) { - this.callback(e, { columnDef: this.columnDef, clearFilterTriggered: this._clearFilterTriggered, shouldTriggerQuery: this._shouldTriggerQuery }); + this.callback(event, { columnDef: this.columnDef, clearFilterTriggered: this._clearFilterTriggered, shouldTriggerQuery: this._shouldTriggerQuery }); this.$filterElm.removeClass('filled'); } else { + const eventType = event?.type ?? ''; + let value = event?.target?.value ?? ''; + const enableWhiteSpaceTrim = this.gridOptions.enableFilterTrimWhiteSpace || this.columnFilter.enableTrimWhiteSpace; + if (typeof value === 'string' && enableWhiteSpaceTrim) { + value = value.trim(); + } value === '' ? this.$filterElm.removeClass('filled') : this.$filterElm.addClass('filled'); - this.callback(e, { columnDef: this.columnDef, operator: this.operator, searchTerms: [value], shouldTriggerQuery: this._shouldTriggerQuery }); + const callbackArgs = { columnDef: this.columnDef, operator: this.operator, searchTerms: [value], shouldTriggerQuery: this._shouldTriggerQuery }; + const typingDelay = (eventType === 'keyup' && event?.key !== 'Enter') ? this._debounceTypingDelay : 0; + + if (typingDelay > 0) { + clearTimeout(this._timer); + this._timer = setTimeout(() => this.callback(event, callbackArgs), typingDelay); + } else { + this.callback(event, callbackArgs); + } } + // reset both flags for next use this._clearFilterTriggered = false; this._shouldTriggerQuery = true; diff --git a/src/app/modules/angular-slickgrid/filters/inputMaskFilter.ts b/src/app/modules/angular-slickgrid/filters/inputMaskFilter.ts index 8e231b821..a0a62cee4 100644 --- a/src/app/modules/angular-slickgrid/filters/inputMaskFilter.ts +++ b/src/app/modules/angular-slickgrid/filters/inputMaskFilter.ts @@ -51,7 +51,7 @@ export class InputMaskFilter extends InputFilter { // step 3, subscribe to the input change event and run the callback when that happens // also add/remove "filled" class for styling purposes - this.$filterElm.on('keyup input', (e: any) => { + this.$filterElm.on('keyup blur change', (e: any) => { let value = ''; if (e && e.target && e.target.value) { let targetValue = e.target.value; diff --git a/src/app/modules/angular-slickgrid/global-grid-options.ts b/src/app/modules/angular-slickgrid/global-grid-options.ts index 7e13c3b04..afb97c953 100644 --- a/src/app/modules/angular-slickgrid/global-grid-options.ts +++ b/src/app/modules/angular-slickgrid/global-grid-options.ts @@ -73,6 +73,7 @@ export const GlobalGridOptions: Partial = { datasetIdPropertyName: 'id', defaultColumnSortFieldId: 'id', defaultFilter: Filters.input, + defaultBackendServiceFilterTypingDebounce: 500, enableFilterTrimWhiteSpace: false, // do we want to trim white spaces on all Filters? defaultFilterPlaceholder: '🔍', defaultFilterRangeOperator: OperatorType.rangeInclusive, @@ -124,6 +125,7 @@ export const GlobalGridOptions: Partial = { sanitizeDataExport: false, useUtf8WithBom: true }, + filterTypingDebounce: 0, forceFitColumns: false, frozenHeaderWidthCalcDifferential: 0, gridMenu: { diff --git a/src/app/modules/angular-slickgrid/models/backendServiceApi.interface.ts b/src/app/modules/angular-slickgrid/models/backendServiceApi.interface.ts index 7e529bd00..eb4d30902 100644 --- a/src/app/modules/angular-slickgrid/models/backendServiceApi.interface.ts +++ b/src/app/modules/angular-slickgrid/models/backendServiceApi.interface.ts @@ -3,7 +3,7 @@ import { Observable } from 'rxjs'; import { BackendService } from './backendService.interface'; export interface BackendServiceApi { - /** How long to wait until we start querying backend to avoid sending too many requests to backend server. Default to 750ms */ + /** How long to wait until we start querying backend to avoid sending too many requests to backend server. Default to 500ms */ filterTypingDebounce?: number; /** Backend Service Options */ diff --git a/src/app/modules/angular-slickgrid/models/gridOption.interface.ts b/src/app/modules/angular-slickgrid/models/gridOption.interface.ts index 4784540c7..f9f66b920 100644 --- a/src/app/modules/angular-slickgrid/models/gridOption.interface.ts +++ b/src/app/modules/angular-slickgrid/models/gridOption.interface.ts @@ -142,6 +142,9 @@ export interface GridOption { syncGridSelectionWithBackendService?: boolean; }; + /** Defaults to 500, how long to wait between each characters that the user types before processing the filtering process when using a Backend Service? */ + defaultBackendServiceFilterTypingDebounce?: number; + /** Defaults to 'id', what is the default column field id to sort when calling clear sorting */ defaultColumnSortFieldId?: string; @@ -301,6 +304,14 @@ export interface GridOption { /** Some default options to set for the export service */ exportOptions?: ExportOption; + /** + * Default to 0, how long to wait between each characters that the user types before processing the filtering process (only applies for local/in-memory grid). + * Especially useful when you have a big dataset and you want to limit the amount of search called (by default every keystroke will trigger a search on the dataset and that is sometime slow). + * This is only used by and relevant to 2 filters (InputFilter & CompoundInputFilter) which are the only ones triggering a search after each character typed. + * NOTE: please note that the BackendServiceApi has its own `filterTypingDebounce` within the `BackendServiceApi` options which is set to 500ms. + */ + filterTypingDebounce?: number; + /** Defaults to 25, which is the grid footer row panel height (must be a number) */ footerRowHeight?: number; diff --git a/src/app/modules/angular-slickgrid/services/__tests__/filter.service.spec.ts b/src/app/modules/angular-slickgrid/services/__tests__/filter.service.spec.ts index cac2305a4..b8eaa9745 100644 --- a/src/app/modules/angular-slickgrid/services/__tests__/filter.service.spec.ts +++ b/src/app/modules/angular-slickgrid/services/__tests__/filter.service.spec.ts @@ -7,6 +7,7 @@ import { of, throwError } from 'rxjs'; import { BackendService, + BackendServiceApi, Column, ColumnFilters, CurrentFilter, @@ -473,7 +474,7 @@ describe('FilterService', () => { const spyClear = jest.spyOn(service.getFiltersMetadata()[0], 'clear'); const spyFilterChange = jest.spyOn(service, 'onBackendFilterChange'); const spyEmitter = jest.spyOn(service, 'emitFilterChanged'); - const spyProcess = jest.spyOn(gridOptionMock.backendServiceApi, 'process'); + const spyProcess = jest.spyOn(gridOptionMock.backendServiceApi as BackendServiceApi, 'process'); const filterCountBefore = Object.keys(service.getColumnFilters()).length; service.clearFilters(); @@ -491,7 +492,7 @@ describe('FilterService', () => { const spyClear = jest.spyOn(service.getFiltersMetadata()[0], 'clear'); const spyFilterChange = jest.spyOn(service, 'onBackendFilterChange'); const spyEmitter = jest.spyOn(service, 'emitFilterChanged'); - const spyProcess = jest.spyOn(gridOptionMock.backendServiceApi, 'process'); + const spyProcess = jest.spyOn(gridOptionMock.backendServiceApi as BackendServiceApi, 'process'); const filterCountBefore = Object.keys(service.getColumnFilters()).length; service.clearFilters(); @@ -513,8 +514,8 @@ describe('FilterService', () => { gridOptionMock.backendServiceApi.process = () => Promise.reject(errorExpected); gridOptionMock.backendServiceApi.onError = (e) => jest.fn(); const spyOnCleared = jest.spyOn(service.onFilterCleared, 'next'); - const spyOnError = jest.spyOn(gridOptionMock.backendServiceApi, 'onError'); - jest.spyOn(gridOptionMock.backendServiceApi, 'process'); + const spyOnError = jest.spyOn(gridOptionMock.backendServiceApi as BackendServiceApi, 'onError'); + jest.spyOn(gridOptionMock.backendServiceApi as BackendServiceApi, 'process'); service.clearFilters(); @@ -531,8 +532,8 @@ describe('FilterService', () => { gridOptionMock.backendServiceApi.process = () => of(spyProcess); gridOptionMock.backendServiceApi.onError = (e) => jest.fn(); const spyOnCleared = jest.spyOn(service.onFilterCleared, 'next'); - const spyOnError = jest.spyOn(gridOptionMock.backendServiceApi, 'onError'); - jest.spyOn(gridOptionMock.backendServiceApi, 'process').mockReturnValue(throwError(errorExpected)); + const spyOnError = jest.spyOn(gridOptionMock.backendServiceApi as BackendServiceApi, 'onError'); + jest.spyOn(gridOptionMock.backendServiceApi as BackendServiceApi, 'process').mockReturnValue(throwError(errorExpected)); service.clearFilters(); @@ -583,7 +584,7 @@ describe('FilterService', () => { }); describe('customLocalFilter method', () => { - let mockItem1; + let mockItem1: any; beforeEach(() => { jest.spyOn(gridStub, 'getColumnIndex').mockReturnValue(0); @@ -596,7 +597,7 @@ describe('FilterService', () => { jest.spyOn(gridStub, 'getColumns').mockReturnValue([]); service.init(gridStub); - const columnFilters = { firstName: { columnDef: mockColumn1, columnId: 'firstName', operator: 'EQ', searchTerms } }; + const columnFilters = { firstName: { columnDef: mockColumn1, columnId: 'firstName', operator: 'EQ', searchTerms, type: FieldType.string } } as ColumnFilters; const output = service.customLocalFilter(mockItem1, { dataView: dataViewStub, grid: gridStub, columnFilters }); expect(columnFilters.firstName['parsedSearchTerms']).toEqual(['John']); @@ -906,14 +907,14 @@ describe('FilterService', () => { }); it('should throw an error when grid argument is undefined', (done) => { - service.onBackendFilterChange(undefined, undefined).catch((error) => { + service.onBackendFilterChange(undefined as any, undefined).catch((error) => { expect(error.message).toContain(`Something went wrong when trying to bind the "onBackendFilterChange(event, args)" function`); done(); }); }); it('should throw an error when grid argument is an empty object', (done) => { - service.onBackendFilterChange(undefined, {}).catch((error) => { + service.onBackendFilterChange(undefined as any, {}).catch((error) => { expect(error.message).toContain(`Something went wrong when trying to bind the "onBackendFilterChange(event, args)" function`); done(); }); @@ -922,37 +923,20 @@ describe('FilterService', () => { it('should throw an error when backendServiceApi is undefined', (done) => { gridOptionMock.backendServiceApi = undefined; - service.onBackendFilterChange(undefined, { grid: gridStub }).catch((error) => { + service.onBackendFilterChange(undefined as any, { grid: gridStub }).catch((error) => { expect(error.message).toContain(`BackendServiceApi requires at least a "process" function and a "service" defined`); done(); }); }); it('should execute "preProcess" method when it is defined', () => { - const spy = jest.spyOn(gridOptionMock.backendServiceApi, 'preProcess'); + const spy = jest.spyOn(gridOptionMock.backendServiceApi as BackendServiceApi, 'preProcess'); service.init(gridStub); - service.onBackendFilterChange(undefined, { grid: gridStub }); + service.onBackendFilterChange(undefined as any, { grid: gridStub }); 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; - const spy = jest.spyOn(gridOptionMock.backendServiceApi.service, 'processOnFilterChanged').mockReturnValue('backend query'); - - service.init(gridStub); - const mockEvent = new Event('input'); - Object.defineProperty(new Event('input'), 'target', { writable: true, configurable: true, value: { value: 'John' } }); - - // @ts-ignore - service.onBackendFilterChange(mockEvent, { grid: gridStub, shouldTriggerQuery: true }); - - setTimeout(() => { - expect(spy).toHaveBeenCalled(); - done(); - }, 1); - }); }); describe('populateColumnFilterSearchTermPresets method', () => { @@ -969,7 +953,7 @@ describe('FilterService', () => { gridStub.getColumns = undefined; service.init(gridStub); - const output = service.populateColumnFilterSearchTermPresets(undefined); + const output = service.populateColumnFilterSearchTermPresets(undefined as any); expect(output).toEqual([]); }); diff --git a/src/app/modules/angular-slickgrid/services/__tests__/sort.service.spec.ts b/src/app/modules/angular-slickgrid/services/__tests__/sort.service.spec.ts index 05c1549d6..8f5f2e8e4 100644 --- a/src/app/modules/angular-slickgrid/services/__tests__/sort.service.spec.ts +++ b/src/app/modules/angular-slickgrid/services/__tests__/sort.service.spec.ts @@ -2,6 +2,7 @@ import { of, throwError } from 'rxjs'; import { BackendService, + BackendServiceApi, Column, ColumnSort, CurrentSorter, @@ -41,7 +42,7 @@ const gridOptionMock = { title: 'Clear all Sorting' }] } -} as GridOption; +} as unknown as GridOption; const dataViewStub = { refresh: jest.fn(), @@ -172,7 +173,7 @@ describe('SortService', () => { const setSortSpy = jest.spyOn(gridStub, 'setSortColumns'); const gridSortSpy = jest.spyOn(gridStub.onSort, 'notify'); - gridStub.getData = () => null; // fake a custom dataview by removing the dataView in shared + gridStub.getData = () => null as any; // fake a custom dataview by removing the dataView in shared const mockMouseEvent = new Event('mouseup'); service.bindLocalOnSort(gridStub); service.clearSortByColumnId(mockMouseEvent, 'firstName'); @@ -365,8 +366,8 @@ describe('SortService', () => { const mockColumn = { id: 'lastName', field: 'lastName', width: 100 } as Column; const expectedSortCol = { columnId: 'lastName', direction: 'ASC' } as CurrentSorter; const spyEmitSort = jest.spyOn(service.onSortChanged, 'next'); - const spyBackendCurrentSort = jest.spyOn(gridOptionMock.backendServiceApi.service, 'getCurrentSorters').mockReturnValue([expectedSortCol]); - const spyBackendProcessSort = jest.spyOn(gridOptionMock.backendServiceApi.service, 'processOnSortChanged').mockReturnValue('backend query'); + const spyBackendCurrentSort = jest.spyOn(gridOptionMock.backendServiceApi!.service, 'getCurrentSorters').mockReturnValue([expectedSortCol]); + const spyBackendProcessSort = jest.spyOn(gridOptionMock.backendServiceApi!.service, 'processOnSortChanged').mockReturnValue('backend query'); const mockSortedCol = { sortCol: mockColumn, sortAsc: true, grid: gridStub } as ColumnSort; service.bindBackendOnSort(gridStub); @@ -381,8 +382,8 @@ describe('SortService', () => { it('should expect some events being triggered when "multiColumnSort" is enabled and multiple sorts are called', () => { const expectedSortCols = [{ columnId: 'lastName', direction: 'ASC' }, { columnId: 'firstName', direction: 'DESC' }] as CurrentSorter[]; const spyEmitSort = jest.spyOn(service.onSortChanged, 'next'); - const spyBackendCurrentSort = jest.spyOn(gridOptionMock.backendServiceApi.service, 'getCurrentSorters').mockReturnValue(expectedSortCols); - const spyBackendProcessSort = jest.spyOn(gridOptionMock.backendServiceApi.service, 'processOnSortChanged').mockReturnValue('backend query'); + const spyBackendCurrentSort = jest.spyOn(gridOptionMock.backendServiceApi!.service, 'getCurrentSorters').mockReturnValue(expectedSortCols); + const spyBackendProcessSort = jest.spyOn(gridOptionMock.backendServiceApi!.service, 'processOnSortChanged').mockReturnValue('backend query'); const mockSortedCols: ColumnSort[] = [ { sortAsc: true, sortCol: { id: 'lastName', field: 'lastName', width: 100 } }, { sortAsc: false, sortCol: { id: 'firstName', field: 'firstName', width: 75 } } @@ -399,11 +400,11 @@ describe('SortService', () => { it('should expect some events being triggered when "multiColumnSort" is enabled and multiple sorts are called & "process" method is an Observable', () => { const processSubject = of(spyProcess); - gridOptionMock.backendServiceApi.process = () => processSubject; + gridOptionMock.backendServiceApi!.process = () => processSubject; const expectedSortCols = [{ columnId: 'lastName', direction: 'ASC' }, { columnId: 'firstName', direction: 'DESC' }] as CurrentSorter[]; const spyEmitSort = jest.spyOn(service.onSortChanged, 'next'); - const spyBackendCurrentSort = jest.spyOn(gridOptionMock.backendServiceApi.service, 'getCurrentSorters').mockReturnValue(expectedSortCols); - const spyBackendProcessSort = jest.spyOn(gridOptionMock.backendServiceApi.service, 'processOnSortChanged').mockReturnValue('backend query'); + const spyBackendCurrentSort = jest.spyOn(gridOptionMock.backendServiceApi!.service, 'getCurrentSorters').mockReturnValue(expectedSortCols); + const spyBackendProcessSort = jest.spyOn(gridOptionMock.backendServiceApi!.service, 'processOnSortChanged').mockReturnValue('backend query'); const mockSortedCols: ColumnSort[] = [ { sortAsc: true, sortCol: { id: 'lastName', field: 'lastName', width: 100 } }, { sortAsc: false, sortCol: { id: 'firstName', field: 'firstName', width: 75 } } @@ -443,29 +444,29 @@ describe('SortService', () => { service: backendServiceStub, preProcess: spyPreProcess, postProcess: spyPostProcess, - process: undefined + process: undefined as any }; gridStub.getOptions = () => gridOptionMock; }); it('should throw an error when not passing a grid in the args', () => { - expect(() => service.onBackendSortChanged(undefined, undefined)).toThrowError('Something went wrong when trying to bind the "onBackendSortChanged(event, args)" function'); + expect(() => service.onBackendSortChanged(undefined, undefined as any)).toThrowError('Something went wrong when trying to bind the "onBackendSortChanged(event, args)" function'); }); it('should throw an error when backend service is missing', () => { - gridOptionMock.backendServiceApi.service = undefined; + gridOptionMock.backendServiceApi!.service = undefined as any; service.bindBackendOnSort(gridStub); expect(() => service.onBackendSortChanged(undefined, { grid: gridStub, sortCols: [] })).toThrowError('BackendServiceApi requires at least a "process" function and a "service" defined'); }); it('should throw an error when backend "process" method is missing', () => { - gridOptionMock.backendServiceApi.process = undefined; + gridOptionMock.backendServiceApi!.process = undefined as any; service.bindBackendOnSort(gridStub); expect(() => service.onBackendSortChanged(undefined, { grid: gridStub, sortCols: [] })).toThrowError('BackendServiceApi requires at least a "process" function and a "service" defined'); }); it('should use an empty grid option object when grid "getOptions" method is not available', () => { - gridStub.getOptions = undefined; + gridStub.getOptions = undefined as any; service.bindBackendOnSort(gridStub); expect(() => service.onBackendSortChanged(undefined, { grid: gridStub, sortCols: [] })).toThrowError('BackendServiceApi requires at least a "process" function and a "service" defined'); @@ -473,10 +474,10 @@ describe('SortService', () => { 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(); - const spyOnError = jest.spyOn(gridOptionMock.backendServiceApi, 'onError'); - jest.spyOn(gridOptionMock.backendServiceApi, 'process'); + gridOptionMock.backendServiceApi!.process = () => Promise.reject(errorExpected); + gridOptionMock.backendServiceApi!.onError = (e) => jest.fn(); + const spyOnError = jest.spyOn(gridOptionMock.backendServiceApi as BackendServiceApi, 'onError'); + jest.spyOn(gridOptionMock.backendServiceApi as BackendServiceApi, 'process'); service.bindBackendOnSort(gridStub); service.onBackendSortChanged(undefined, { multiColumnSort: true, sortCols: [], grid: gridStub }); @@ -489,10 +490,10 @@ describe('SortService', () => { it('should execute the "onError" method when the Observable throws an error', (done) => { const errorExpected = 'observable error'; - gridOptionMock.backendServiceApi.process = () => of(spyProcess); - gridOptionMock.backendServiceApi.onError = (e) => jest.fn(); - const spyOnError = jest.spyOn(gridOptionMock.backendServiceApi, 'onError'); - jest.spyOn(gridOptionMock.backendServiceApi, 'process').mockReturnValue(throwError(errorExpected)); + gridOptionMock.backendServiceApi!.process = () => of(spyProcess); + gridOptionMock.backendServiceApi!.onError = (e) => jest.fn(); + const spyOnError = jest.spyOn(gridOptionMock.backendServiceApi as BackendServiceApi, 'onError'); + jest.spyOn(gridOptionMock.backendServiceApi as BackendServiceApi, 'process').mockReturnValue(throwError(errorExpected)); service.bindBackendOnSort(gridStub); service.onBackendSortChanged(undefined, { multiColumnSort: true, sortCols: [], grid: gridStub }); @@ -576,10 +577,10 @@ describe('SortService', () => { mockColumns.forEach(col => { expect(col.sortable).toBeFalsy(); }); - mockColumns.forEach(col => col.header.menu.items.forEach(item => { + mockColumns.forEach(col => col.header!.menu!.items.forEach(item => { expect((item as MenuCommandItem).hidden).toBeTruthy(); })); - gridOptionMock.gridMenu.customItems.forEach(item => { + gridOptionMock.gridMenu!.customItems!.forEach(item => { expect((item as GridMenuItem).hidden).toBeTruthy(); }); }); @@ -597,10 +598,10 @@ describe('SortService', () => { mockColumns.forEach(col => { expect(col.sortable).toBeFalsy(); }); - mockColumns.forEach(col => col.header.menu.items.forEach(item => { + mockColumns.forEach(col => col.header!.menu!.items.forEach(item => { expect((item as MenuCommandItem).hidden).toBeTruthy(); })); - gridOptionMock.gridMenu.customItems.forEach(item => { + gridOptionMock.gridMenu!.customItems!.forEach(item => { expect((item as GridMenuItem).hidden).toBeTruthy(); }); }); @@ -616,10 +617,10 @@ describe('SortService', () => { mockColumns.forEach(col => { expect(col.sortable).toBeTruthy(); }); - mockColumns.forEach(col => col.header.menu.items.forEach(item => { + mockColumns.forEach(col => col.header!.menu!.items.forEach(item => { expect((item as MenuCommandItem).hidden).toBeFalsy(); })); - gridOptionMock.gridMenu.customItems.forEach(item => { + gridOptionMock.gridMenu!.customItems!.forEach(item => { expect((item as GridMenuItem).hidden).toBeFalsy(); }); @@ -681,9 +682,12 @@ describe('SortService', () => { ]; service.bindLocalOnSort(gridStub); - service.loadGridSorters(gridOptionMock.presets.sorters); + service.loadGridSorters(gridOptionMock.presets!.sorters as CurrentSorter[]); - expect(spySetCols).toHaveBeenCalledWith(expectation); + expect(spySetCols).toHaveBeenCalledWith([ + { columnId: 'firstName', sortAsc: true, }, + { columnId: 'lastName', sortAsc: false }, + ]); expect(spySortChanged).toHaveBeenCalledWith(gridStub, expectation); }); }); @@ -694,22 +698,22 @@ describe('SortService', () => { sorters: [{ columnId: 'firstName', direction: 'ASC' }, { columnId: 'lastName', direction: 'DESC' }], }; const spySetCols = jest.spyOn(gridStub, 'setSortColumns'); - gridStub.getColumns = undefined; + gridStub.getColumns = undefined as any; service.bindLocalOnSort(gridStub); - service.loadGridSorters(gridOptionMock.presets.sorters); + service.loadGridSorters(gridOptionMock.presets.sorters as CurrentSorter[]); - expect(spySetCols).not.toHaveBeenCalled(); + expect(spySetCols).toHaveBeenCalledWith([]); }); it('should use an empty grid option object when grid "getOptions" method is not available', () => { const spySetCols = jest.spyOn(gridStub, 'setSortColumns'); - gridStub.getOptions = undefined; + gridStub.getOptions = undefined as any; service.bindLocalOnSort(gridStub); - service.loadGridSorters(gridOptionMock.presets.sorters); + service.loadGridSorters(gridOptionMock.presets!.sorters as CurrentSorter[]); - expect(spySetCols).not.toHaveBeenCalled(); + expect(spySetCols).toHaveBeenCalledWith([]); }); }); @@ -744,7 +748,7 @@ describe('SortService', () => { }); describe('sortComparer method', () => { - let dataset = []; + let dataset: any[] = []; beforeEach(() => { dataset = [ @@ -1024,7 +1028,7 @@ describe('SortService', () => { }); describe('Hierarchical Dataset', () => { - let dataset = []; + let dataset: any[] = []; const expectedSortedAscDataset = [ { __parentId: null, __treeLevel: 0, dateModified: '2012-03-05T12:44:00.123Z', file: 'bucket-list.txt', id: 24, size: 0.5 }, { __hasChildren: true, __parentId: null, __treeLevel: 0, file: 'documents', id: 21 }, diff --git a/src/app/modules/angular-slickgrid/services/filter.service.ts b/src/app/modules/angular-slickgrid/services/filter.service.ts index 53d1bcccf..c90858f34 100644 --- a/src/app/modules/angular-slickgrid/services/filter.service.ts +++ b/src/app/modules/angular-slickgrid/services/filter.service.ts @@ -32,10 +32,6 @@ import { SharedService } from './shared.service'; declare const Slick: any; declare const $: any; -// timer for keeping track of user typing waits -let timer: any; -const DEFAULT_FILTER_TYPING_DEBOUNCE = 500; - @Injectable() export class FilterService { protected _eventHandler: SlickEventHandler; @@ -642,32 +638,11 @@ export class FilterService { backendApi.preProcess(); } - // only add a delay when user is typing, on select dropdown filter (or "Clear Filter") it will execute right away - let debounceTypingDelay = 0; - const isTriggeredByClearFilter = args && args.clearFilterTriggered; // was it trigger by a "Clear Filter" command? - - 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; - } - // 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 - // @deprecated TODO: remove async/await on next major change, refer to processOnFilterChanged in BackendService interface (with @deprecated) - clearTimeout(timer); - if (debounceTypingDelay > 0) { - timer = setTimeout(async () => { - const query = await backendApi.service.processOnFilterChanged(event, args); - const totalItems = this._gridOptions && this._gridOptions.pagination && this._gridOptions.pagination.totalItems; - executeBackendCallback(backendApi, query, args, startTime, totalItems, this.emitFilterChanged.bind(this), this.httpCancelRequests$); - }, debounceTypingDelay); - } else { - const query = await backendApi.service.processOnFilterChanged(event, args); - const totalItems = this._gridOptions && this._gridOptions.pagination && this._gridOptions.pagination.totalItems; - executeBackendCallback(backendApi, query, args, startTime, totalItems, this.emitFilterChanged.bind(this), this.httpCancelRequests$); - } + if (args?.shouldTriggerQuery) { + const query = await 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)); } } @@ -678,7 +653,7 @@ export class FilterService { * At the end of the day, when creating the Filter (DOM Element), it will use these searchTerm(s) so we can take advantage of that without recoding each Filter type (DOM element) */ populateColumnFilterSearchTermPresets(filters: CurrentFilter[]) { - if (Array.isArray(filters) && filters.length > 0) { + if (Array.isArray(filters)) { this._columnDefinitions.forEach((columnDef: Column) => { // clear any columnDef searchTerms before applying Presets if (columnDef.filter && columnDef.filter.searchTerms) { diff --git a/src/app/modules/angular-slickgrid/services/sort.service.ts b/src/app/modules/angular-slickgrid/services/sort.service.ts index 53d83ae33..5c8571b8f 100644 --- a/src/app/modules/angular-slickgrid/services/sort.service.ts +++ b/src/app/modules/angular-slickgrid/services/sort.service.ts @@ -299,11 +299,9 @@ export class SortService { }); } }); - } - if (sortCols.length > 0) { this.onLocalSortChanged(this._grid, sortCols); - this._grid.setSortColumns(sortCols); // use this to add sort icon(s) in UI + this._grid.setSortColumns(sortCols.map(col => ({ columnId: col.columnId, sortAsc: col.sortAsc }))); // use this to add sort icon(s) in UI } return sortCols;