Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(filters): add optional filterTypingDebounce for filters w/keyup #283

Merged
merged 1 commit into from
Mar 15, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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