-
-
Notifications
You must be signed in to change notification settings - Fork 21
/
inputFilter.ts
356 lines (313 loc) · 13.9 KB
/
inputFilter.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
import { BindingEventService } from '@slickgrid-universal/binding';
import { createDomElement, emptyElement, isDefined, toSentenceCase } from '@slickgrid-universal/utils';
import type {
Column,
ColumnFilter,
Filter,
FilterArguments,
FilterCallback,
GridOption,
OperatorDetail,
} from '../interfaces/index';
import { FieldType, OperatorType, type OperatorString, type SearchTerm } from '../enums/index';
import { applyOperatorAltTextWhenExists, buildSelectOperator, compoundOperatorNumeric, compoundOperatorString } from './filterUtilities';
import { mapOperatorToShorthandDesignation, type TranslaterService, } from '../services';
import { type SlickGrid } from '../core/index';
export class InputFilter implements Filter {
protected _bindEventService: BindingEventService;
protected _currentValue?: number | string;
protected _debounceTypingDelay = 0;
protected _shouldTriggerQuery = true;
protected _inputType = 'text';
protected _timer?: NodeJS.Timeout;
protected _cellContainerElm!: HTMLDivElement;
protected _filterContainerElm!: HTMLDivElement;
protected _filterInputElm!: HTMLInputElement;
protected _lastSearchValue?: number | string;
protected _selectOperatorElm?: HTMLSelectElement;
inputFilterType: 'single' | 'compound' = 'single';
grid!: SlickGrid;
searchTerms: SearchTerm[] = [];
columnDef!: Column;
callback!: FilterCallback;
constructor(protected readonly translaterService?: TranslaterService | undefined) {
this._bindEventService = new BindingEventService();
}
/** Getter for the Column Filter */
get columnFilter(): ColumnFilter {
return this.columnDef?.filter ?? {};
}
/** Getter to know what would be the default operator when none is specified */
get defaultOperator(): OperatorType | OperatorString {
return OperatorType.empty;
}
/** Getter of input type (text, number, password) */
get inputType(): string {
return this._inputType;
}
/** Setter of input type (text, number, password) */
set inputType(type: string) {
this._inputType = type;
}
/** Getter for the Filter Operator */
get operator(): OperatorType | OperatorString {
return this.columnFilter?.operator ?? this.defaultOperator;
}
/** Setter for the Filter Operator */
set operator(operator: OperatorType | OperatorString) {
if (this.columnFilter) {
this.columnFilter.operator = operator;
}
}
/** Getter for the Grid Options pulled through the Grid Object */
protected get gridOptions(): GridOption {
return this.grid?.getOptions() ?? {};
}
/**
* Initialize the Filter
*/
init(args: FilterArguments): void {
if (!args) {
throw new Error('[Slickgrid-Universal] A filter must always have an "init()" with valid arguments.');
}
this.grid = args.grid;
this.callback = args.callback;
this.columnDef = args.columnDef;
if (this.inputFilterType === 'compound') {
this.operator = args.operator || '';
}
this.searchTerms = args?.searchTerms ?? [];
this._cellContainerElm = args.filterContainerElm;
// 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] : '';
// step 1, create the DOM Element of the filter & initialize it if searchTerm is filled
this.createDomFilterElement(searchTerm);
// step 2, subscribe to the input event and run the callback when that happens
// also add/remove "filled" class for styling purposes
// we'll use all necessary events to cover the following (keyup, change, mousewheel & spinner)
this._bindEventService.bind(this._filterInputElm, ['keyup', 'blur', 'change'], this.onTriggerEvent.bind(this) as EventListener);
this._bindEventService.bind(this._filterInputElm, 'wheel', this.onTriggerEvent.bind(this) as EventListener, { passive: true });
if (this.inputFilterType === 'compound' && this._selectOperatorElm) {
this._bindEventService.bind(this._selectOperatorElm, 'change', this.onTriggerEvent.bind(this) as EventListener);
}
}
/**
* Clear the filter value
*/
clear(shouldTriggerQuery = true): void {
if (this._filterInputElm) {
this._shouldTriggerQuery = shouldTriggerQuery;
this.searchTerms = [];
this._filterInputElm.value = '';
this._currentValue = undefined;
if (this.inputFilterType === 'compound' && this._selectOperatorElm) {
this._selectOperatorElm.selectedIndex = 0;
this._filterContainerElm.classList.remove('filled');
}
this._filterInputElm.classList.remove('filled');
this.onTriggerEvent(undefined, true);
}
}
/**
* destroy the filter
*/
destroy(): void {
this._bindEventService.unbindAll();
this._selectOperatorElm?.remove?.();
this._filterInputElm?.remove?.();
}
getValues(): string {
return this._filterInputElm.value;
}
/** Set value(s) on the DOM element */
setValues(values: SearchTerm | SearchTerm[], operator?: OperatorType | OperatorString): void {
const searchValues = Array.isArray(values) ? values : [values];
let newInputValue: SearchTerm = '';
for (const value of searchValues) {
if (this.inputFilterType === 'single') {
newInputValue = operator ? this.addOptionalOperatorIntoSearchString(value, operator) : value;
} else {
newInputValue = `${value}`;
}
this._filterInputElm.value = `${newInputValue ?? ''}`;
this._currentValue = this._filterInputElm.value;
}
if (this.getValues() !== '') {
this._filterContainerElm.classList.add('filled');
this._filterInputElm.classList.add('filled');
} else {
this._filterContainerElm.classList.remove('filled');
this._filterInputElm.classList.remove('filled');
}
// set the operator when defined
this.operator = operator || this.defaultOperator;
if (operator && this._selectOperatorElm) {
const operatorShorthand = mapOperatorToShorthandDesignation(this.operator);
this._selectOperatorElm.value = operatorShorthand;
}
}
//
// protected functions
// ------------------
/**
* When loading the search string from the outside into the input text field, we should also add the prefix/suffix of the operator.
* We do this so that if it was loaded by a Grid Presets then we should also add the operator into the search string
* Let's take these 3 examples:
* 1. (operator: '>=', searchTerms:[55]) should display as ">=55"
* 2. (operator: 'StartsWith', searchTerms:['John']) should display as "John*"
* 3. (operator: 'EndsWith', searchTerms:['John']) should display as "*John"
* @param operator - operator string
*/
protected addOptionalOperatorIntoSearchString(inputValue: SearchTerm, operator: OperatorType | OperatorString): string {
let searchTermPrefix = '';
let searchTermSuffix = '';
let outputValue = inputValue === undefined || inputValue === null ? '' : `${inputValue}`;
if (operator && outputValue) {
switch (operator) {
case '<>':
case '!=':
case '=':
case '==':
case '>':
case '>=':
case '<':
case '<=':
searchTermPrefix = operator;
break;
case 'EndsWith':
case '*z':
searchTermPrefix = '*';
break;
case 'StartsWith':
case 'a*':
searchTermSuffix = '*';
break;
}
outputValue = `${searchTermPrefix}${outputValue}${searchTermSuffix}`;
}
return outputValue;
}
/** Get the available operator option values to populate the operator select dropdown list */
protected getCompoundOperatorOptionValues(): OperatorDetail[] {
const type = (this.columnDef.type && this.columnDef.type) ? this.columnDef.type : FieldType.string;
let operatorList: OperatorDetail[];
let listType: 'text' | 'numeric' = 'text';
if (this.columnFilter?.compoundOperatorList) {
operatorList = this.columnFilter.compoundOperatorList;
} else {
switch (type) {
case FieldType.string:
case FieldType.text:
case FieldType.readonly:
case FieldType.password:
listType = 'text';
operatorList = compoundOperatorString(this.gridOptions, this.translaterService);
break;
default:
listType = 'numeric';
operatorList = compoundOperatorNumeric(this.gridOptions, this.translaterService);
break;
}
}
// add alternate texts when provided
applyOperatorAltTextWhenExists(this.gridOptions, operatorList, listType);
return operatorList;
}
/**
* From the html template string, create a DOM element
* @param {Object} searchTerm - filter search term
* @returns {Object} DOM element filter
*/
protected createDomFilterElement(searchTerm?: SearchTerm): void {
const columnId = this.columnDef?.id ?? '';
emptyElement(this._cellContainerElm);
// create the DOM element & add an ID and filter class
let placeholder = this.gridOptions?.defaultFilterPlaceholder ?? '';
if (this.columnFilter?.placeholder) {
placeholder = this.columnFilter.placeholder;
}
const searchVal = `${searchTerm ?? ''}`;
this._filterInputElm = createDomElement('input', {
type: this._inputType || 'text',
autocomplete: 'off', ariaAutoComplete: 'none', placeholder,
ariaLabel: this.columnFilter?.ariaLabel ?? `${toSentenceCase(columnId + '')} Search Filter`,
className: `form-control filter-${columnId}`,
value: searchVal,
dataset: { columnid: `${columnId}` }
});
// if there's a search term, we will add the "filled" class for styling purposes
if (searchTerm) {
this._filterInputElm.classList.add('filled');
}
if (searchTerm !== undefined) {
this._currentValue = searchVal;
}
// create the DOM Select dropdown for the Operator
if (this.inputFilterType === 'single') {
this._filterContainerElm = this._filterInputElm;
// append the new DOM element to the header row & an empty span
this._filterInputElm.classList.add('search-filter');
this._cellContainerElm.appendChild(this._filterInputElm);
this._cellContainerElm.appendChild(document.createElement('span'));
} else {
// compound filter
this._filterInputElm.classList.add('compound-input');
this._selectOperatorElm = buildSelectOperator(this.getCompoundOperatorOptionValues(), this.grid);
this._filterContainerElm = createDomElement('div', { className: `form-group search-filter filter-${columnId}` });
const containerInputGroupElm = createDomElement('div', { className: 'input-group' }, this._filterContainerElm);
const operatorInputGroupAddonElm = createDomElement('div', { className: 'input-group-addon input-group-prepend operator' }, containerInputGroupElm);
// append operator & input DOM element
operatorInputGroupAddonElm.appendChild(this._selectOperatorElm);
containerInputGroupElm.appendChild(this._filterInputElm);
containerInputGroupElm.appendChild(createDomElement('span'));
if (this.operator) {
this._selectOperatorElm.value = mapOperatorToShorthandDesignation(this.operator);
}
// append the new DOM element to the header row
if (this._filterContainerElm) {
this._cellContainerElm.appendChild(this._filterContainerElm);
}
}
}
/**
* Event handler to cover the following (keyup, change, mousewheel & spinner)
* We will trigger the Filter Service callback from this handler
*/
protected onTriggerEvent(event?: MouseEvent | KeyboardEvent, isClearFilterEvent = false): void {
if (isClearFilterEvent) {
this.callback(event, { columnDef: this.columnDef, clearFilterTriggered: isClearFilterEvent, shouldTriggerQuery: this._shouldTriggerQuery });
this._filterContainerElm.classList.remove('filled');
} else {
const eventType = event?.type ?? '';
const selectedOperator = (this._selectOperatorElm?.value ?? this.operator) as OperatorString;
let value = this._filterInputElm.value;
const enableWhiteSpaceTrim = this.gridOptions.enableFilterTrimWhiteSpace || this.columnFilter.enableTrimWhiteSpace;
if (typeof value === 'string' && enableWhiteSpaceTrim) {
value = value.trim();
}
if ((event?.target as HTMLElement)?.tagName.toLowerCase() !== 'select') {
this._currentValue = value;
}
value === '' ? this._filterContainerElm.classList.remove('filled') : this._filterContainerElm.classList.add('filled');
const callbackArgs = { columnDef: this.columnDef, operator: selectedOperator, searchTerms: (value ? [value] : null), shouldTriggerQuery: this._shouldTriggerQuery };
const typingDelay = (eventType === 'keyup' && (event as KeyboardEvent)?.key !== 'Enter') ? this._debounceTypingDelay : 0;
const skipNullInput = this.columnFilter.skipCompoundOperatorFilterWithNullInput ?? this.gridOptions.skipCompoundOperatorFilterWithNullInput;
const hasSkipNullValChanged = (skipNullInput && isDefined(this._currentValue)) || (this._currentValue === '' && isDefined(this._lastSearchValue));
if (this.inputFilterType === 'single' || !skipNullInput || hasSkipNullValChanged) {
if (typingDelay > 0) {
clearTimeout(this._timer as NodeJS.Timeout);
this._timer = setTimeout(() => this.callback(event, callbackArgs), typingDelay);
} else {
this.callback(event, callbackArgs);
}
}
this._lastSearchValue = value;
}
// reset both flags for next use
this._shouldTriggerQuery = true;
}
}