Skip to content

Commit

Permalink
feat(filters): add optional filterTypingDebounce for keyboard filte…
Browse files Browse the repository at this point in the history
…rs (#283)

- we previously had the `filterTypingDebounce` but that was only available when using `BackendServiceApi` (with OData/GraphQL) but this PR goes further and makes it available to any type of grids, so it can now be used with a local grid (JSON dataset)
- we also move the debounce code from the Filter Service into each filter component itself (only the following 2 filters requires this, `InputFilter` and `CompoundInputFilter`). The Filter Service shouldn't do any debounce, it should be the responsability of the concerned filter and that is what we refactored in this PR as well.
  • Loading branch information
ghiscoding authored Mar 15, 2021
1 parent e44145d commit bb7dcd3
Show file tree
Hide file tree
Showing 18 changed files with 159 additions and 109 deletions.
6 changes: 4 additions & 2 deletions packages/common/src/editors/dualInputEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
SlickGrid,
SlickNamespace,
} from '../interfaces/index';
import { debounce, getDescendantProperty, setDeepValue } from '../services/utilities';
import { getDescendantProperty, setDeepValue } from '../services/utilities';
import { floatValidator, integerValidator, textValidator } from '../editorValidators';
import { BindingEventService } from '../services/bindingEvent.service';

Expand All @@ -40,6 +40,7 @@ export class DualInputEditor implements Editor {
protected _rightFieldName!: string;
protected _originalLeftValue!: string | number;
protected _originalRightValue!: string | number;
protected _timer?: NodeJS.Timeout;

/** is the Editor disabled? */
disabled = false;
Expand Down Expand Up @@ -538,7 +539,8 @@ export class DualInputEditor implements Editor {
const compositeEditorOptions = this.args?.compositeEditorOptions;
if (compositeEditorOptions) {
const typingDelay = this.gridOptions?.editorTypingDebounce ?? 500;
debounce(() => this.handleChangeOnCompositeEditor(event, compositeEditorOptions), typingDelay)();
clearTimeout(this._timer as NodeJS.Timeout);
this._timer = setTimeout(() => this.handleChangeOnCompositeEditor(event, compositeEditorOptions), typingDelay);
}
}
}
6 changes: 4 additions & 2 deletions packages/common/src/editors/floatEditor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { KeyCode } from '../enums/index';
import { Column, ColumnEditor, CompositeEditorOption, Editor, EditorArguments, EditorValidator, EditorValidationResult, GridOption, SlickGrid, SlickNamespace, } from '../interfaces/index';
import { debounce, getDescendantProperty, setDeepValue } from '../services/utilities';
import { getDescendantProperty, setDeepValue } from '../services/utilities';
import { floatValidator } from '../editorValidators/floatValidator';
import { BindingEventService } from '../services/bindingEvent.service';

Expand All @@ -19,6 +19,7 @@ export class FloatEditor implements Editor {
protected _isValueTouched = false;
protected _lastInputKeyEvent?: KeyboardEvent;
protected _originalValue?: number | string;
protected _timer?: NodeJS.Timeout;

/** is the Editor disabled? */
disabled = false;
Expand Down Expand Up @@ -367,7 +368,8 @@ export class FloatEditor implements Editor {
const compositeEditorOptions = this.args.compositeEditorOptions;
if (compositeEditorOptions) {
const typingDelay = this.gridOptions?.editorTypingDebounce ?? 500;
debounce(() => this.handleChangeOnCompositeEditor(event, compositeEditorOptions), typingDelay)();
clearTimeout(this._timer as NodeJS.Timeout);
this._timer = setTimeout(() => this.handleChangeOnCompositeEditor(event, compositeEditorOptions), typingDelay);
}
}
}
6 changes: 4 additions & 2 deletions packages/common/src/editors/integerEditor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { KeyCode } from '../enums/index';
import { Column, ColumnEditor, CompositeEditorOption, Editor, EditorArguments, EditorValidator, EditorValidationResult, GridOption, SlickGrid, SlickNamespace, } from './../interfaces/index';
import { debounce, getDescendantProperty, setDeepValue } from '../services/utilities';
import { getDescendantProperty, setDeepValue } from '../services/utilities';
import { integerValidator } from '../editorValidators/integerValidator';
import { BindingEventService } from '../services/bindingEvent.service';

Expand All @@ -17,6 +17,7 @@ export class IntegerEditor implements Editor {
protected _isValueTouched = false;
protected _input!: HTMLInputElement | undefined;
protected _originalValue?: number | string;
protected _timer?: NodeJS.Timeout;

/** is the Editor disabled? */
disabled = false;
Expand Down Expand Up @@ -330,7 +331,8 @@ export class IntegerEditor implements Editor {
const compositeEditorOptions = this.args.compositeEditorOptions;
if (compositeEditorOptions) {
const typingDelay = this.gridOptions?.editorTypingDebounce ?? 500;
debounce(() => this.handleChangeOnCompositeEditor(event, compositeEditorOptions), typingDelay)();
clearTimeout(this._timer as NodeJS.Timeout);
this._timer = setTimeout(() => this.handleChangeOnCompositeEditor(event, compositeEditorOptions), typingDelay);
}
}
}
6 changes: 4 additions & 2 deletions packages/common/src/editors/longTextEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
SlickGrid,
SlickNamespace,
} from '../interfaces/index';
import { debounce, getDescendantProperty, getHtmlElementOffset, getTranslationPrefix, setDeepValue, } from '../services/utilities';
import { getDescendantProperty, getHtmlElementOffset, getTranslationPrefix, setDeepValue, } from '../services/utilities';
import { TranslaterService } from '../services/translater.service';
import { textValidator } from '../editorValidators/textValidator';

Expand All @@ -31,6 +31,7 @@ export class LongTextEditor implements Editor {
protected _defaultTextValue: any;
protected _isValueTouched = false;
protected _locales: Locale;
protected _timer?: NodeJS.Timeout;
protected _$textarea: any;
protected _$currentLengthElm: any;
protected _$wrapper: any;
Expand Down Expand Up @@ -432,7 +433,8 @@ export class LongTextEditor implements Editor {
// when using a Composite Editor, we'll want to add a debounce delay to avoid perf issue since Composite could affect other editors in the same form
if (compositeEditorOptions) {
const typingDelay = this.gridOptions?.editorTypingDebounce ?? 500;
debounce(() => this.handleChangeOnCompositeEditor(event, compositeEditorOptions), typingDelay)();
clearTimeout(this._timer as NodeJS.Timeout);
this._timer = setTimeout(() => this.handleChangeOnCompositeEditor(event, compositeEditorOptions), typingDelay);
}
}

Expand Down
6 changes: 4 additions & 2 deletions packages/common/src/editors/textEditor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { KeyCode } from '../enums/keyCode.enum';
import { Column, ColumnEditor, CompositeEditorOption, Editor, EditorArguments, EditorValidator, EditorValidationResult, GridOption, SlickGrid, SlickNamespace, } from '../interfaces/index';
import { debounce, getDescendantProperty, setDeepValue } from '../services/utilities';
import { getDescendantProperty, setDeepValue } from '../services/utilities';
import { textValidator } from '../editorValidators/textValidator';
import { BindingEventService } from '../services/bindingEvent.service';

Expand All @@ -17,6 +17,7 @@ export class TextEditor implements Editor {
protected _isValueTouched = false;
protected _lastInputKeyEvent?: KeyboardEvent;
protected _originalValue?: string;
protected _timer?: NodeJS.Timeout;

/** is the Editor disabled? */
disabled = false;
Expand Down Expand Up @@ -308,7 +309,8 @@ export class TextEditor implements Editor {
const compositeEditorOptions = this.args.compositeEditorOptions;
if (compositeEditorOptions) {
const typingDelay = this.gridOptions?.editorTypingDebounce ?? 500;
debounce(() => this.handleChangeOnCompositeEditor(event, compositeEditorOptions), typingDelay)();
clearTimeout(this._timer as NodeJS.Timeout);
this._timer = setTimeout(() => this.handleChangeOnCompositeEditor(event, compositeEditorOptions), typingDelay);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FieldType, OperatorType } from '../../enums/index';
import { Column, FilterArguments, GridOption, SlickGrid } from '../../interfaces/index';
import { BackendServiceApi, Column, FilterArguments, GridOption, SlickGrid } from '../../interfaces/index';
import { Filters } from '../index';
import { CompoundInputFilter } from '../compoundInputFilter';
import { TranslateServiceStub } from '../../../../../test/translateServiceStub';
Expand Down Expand Up @@ -215,6 +215,44 @@ describe('CompoundInputFilter', () => {
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('input', { 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('input', { 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'];

Expand Down
39 changes: 38 additions & 1 deletion packages/common/src/filters/__tests__/inputFilter.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { InputFilter } from '../inputFilter';
import { GridOption, FilterArguments, Column, SlickGrid } from '../../interfaces/index';
import { GridOption, FilterArguments, Column, SlickGrid, BackendServiceApi } from '../../interfaces/index';
import { Filters } from '..';

const containerId = 'demo-container';
Expand Down Expand Up @@ -164,6 +164,43 @@ describe('InputFilter', () => {
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('input', { 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('input', { 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'];

Expand Down
25 changes: 20 additions & 5 deletions packages/common/src/filters/compoundInputFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { TranslaterService } from '../services/translater.service';

export class CompoundInputFilter implements Filter {
protected _clearFilterTriggered = false;
protected _debounceTypingDelay = 0;
protected _shouldTriggerQuery = true;
protected _inputType = 'text';
protected $filterElm: any;
Expand All @@ -26,6 +27,7 @@ export class CompoundInputFilter implements Filter {
searchTerms: SearchTerm[] = [];
columnDef!: Column;
callback!: FilterCallback;
timer?: NodeJS.Timeout;

constructor(protected readonly translaterService: TranslaterService) { }

Expand Down Expand Up @@ -83,6 +85,11 @@ export class CompoundInputFilter implements Filter {
this.operator = args.operator || '';
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] : '';

Expand All @@ -92,7 +99,7 @@ export class CompoundInputFilter 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.$filterInputElm.on('keyup input', this.onTriggerEvent.bind(this));
this.$filterInputElm.on('keyup input blur', this.onTriggerEvent.bind(this));
this.$selectOperatorElm.on('change', this.onTriggerEvent.bind(this));
}

Expand Down Expand Up @@ -246,14 +253,14 @@ export class CompoundInputFilter implements Filter {
}

/** Event trigger, could be called by the Operator dropdown or the input itself */
protected onTriggerEvent(e: KeyboardEvent | undefined) {
protected onTriggerEvent(event: 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') {
if (event && event.type === 'keyup' && event.key !== 'Enter') {
return;
}
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 selectedOperator = this.$selectOperatorElm.find('option:selected').val();
Expand All @@ -264,7 +271,15 @@ export class CompoundInputFilter implements Filter {
}

(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 = (event?.key === 'Enter' || event?.type === 'blur') ? 0 : this._debounceTypingDelay;

if (typingDelay > 0) {
clearTimeout(this.timer as NodeJS.Timeout);
this.timer = setTimeout(() => this.callback(event, callbackArgs), typingDelay);
} else {
this.callback(event, callbackArgs);
}
}

// reset both flags for next use
Expand Down
35 changes: 25 additions & 10 deletions packages/common/src/filters/inputFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import { OperatorType, OperatorString, SearchTerm } from '../enums/index';

export class InputFilter implements Filter {
protected _clearFilterTriggered = false;
protected _debounceTypingDelay = 0;
protected _shouldTriggerQuery = true;
protected _inputType = 'text';
protected _timer?: NodeJS.Timeout;
protected $filterElm: any;
grid!: SlickGrid;
searchTerms: SearchTerm[] = [];
Expand Down Expand Up @@ -70,6 +72,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] : '';

Expand All @@ -81,7 +88,7 @@ export class InputFilter implements Filter {

// step 3, subscribe to the input 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));
this.$filterElm.on('keyup input blur', this.handleInputChange.bind(this));
}

/**
Expand Down Expand Up @@ -161,24 +168,32 @@ export class InputFilter implements Filter {
return $filterElm;
}

protected handleInputChange(e: any) {
protected handleInputChange(event: KeyboardEvent & { target: 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') {
if (event && event.type === 'keyup' && event.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();
}

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 {
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 = (event?.key === 'Enter' || event?.type === 'blur') ? 0 : this._debounceTypingDelay;

if (typingDelay > 0) {
clearTimeout(this._timer as NodeJS.Timeout);
this._timer = setTimeout(() => this.callback(event, callbackArgs), typingDelay);
} else {
this.callback(event, callbackArgs);
}
}

// reset both flags for next use
Expand Down
Loading

0 comments on commit bb7dcd3

Please sign in to comment.