Skip to content

Commit 7e35dae

Browse files
authored
fix(filters): provide flag to disable special chars input filter parsing (#873)
- Slickgrid-Universal is by default parsing some special characters (<, >, =, *) when found in filter input value but in some rare occasion the user might have data that includes these special chars and might want to disable the parsing, this PR provide a new flag to do that - ref Stack Overflow [question](https://stackoverflow.com/questions/75155658/in-angular-slickgrid-the-records-with-special-characters-are-not-gett/75160978#75160978)
1 parent 48d94e8 commit 7e35dae

File tree

12 files changed

+186
-28
lines changed

12 files changed

+186
-28
lines changed

.vscode/launch.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333
],
3434
"console": "internalConsole",
3535
"internalConsoleOptions": "neverOpen",
36-
"disableOptimisticBPs": true,
3736
"windows": {
37+
"name": "Jest",
3838
"program": "${workspaceFolder}/node_modules/jest/bin/jest"
3939
}
4040
}

package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
"test:watch": "cross-env TZ='America/New_York' jest --watch --config ./test/jest.config.js"
4343
},
4444
"comments": {
45-
"new-version": "To create a new version with Lerna-Lite, simply run the following script (1) 'roll-new-release'."
45+
"new-version": "To create a new version with Lerna-Lite, simply run the following script (1) 'roll-new-release'.",
46+
"devDependencies": "The dev deps 'jQuery', 'slickgrid', 'sortablejs' and 'whatwg-fetch' are simply installed for Jest unit tests."
4647
},
4748
"devDependencies": {
4849
"@4tw/cypress-drag-drop": "^2.2.3",
@@ -63,6 +64,7 @@
6364
"jest-cli": "^29.3.1",
6465
"jest-environment-jsdom": "^29.3.1",
6566
"jest-extended": "^3.2.3",
67+
"jquery": "^3.6.3",
6668
"jsdom": "^21.0.0",
6769
"jsdom-global": "^3.0.2",
6870
"moment-mini": "^2.29.4",
@@ -71,8 +73,11 @@
7173
"rimraf": "^3.0.2",
7274
"rxjs": "^7.5.7",
7375
"serve": "^14.1.2",
76+
"slickgrid": "^3.0.2",
77+
"sortablejs": "^1.15.0",
7478
"ts-jest": "^29.0.5",
75-
"typescript": "^4.9.4"
79+
"typescript": "^4.9.4",
80+
"whatwg-fetch": "^3.6.2"
7681
},
7782
"packageManager": "[email protected]",
7883
"engines": {

packages/common/src/global-grid-options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const GlobalGridOptions: GridOption = {
1313
autoFixResizeTimeout: 5 * 60 * 5, // interval is 200ms, so 4x is 1sec, so (5 * 60 * 5 = 5min)
1414
autoFixResizeRequiredGoodCount: 2,
1515
autoFixResizeWhenBrokenStyleDetected: false,
16+
autoParseInputFilterOperator: true,
1617
autoResize: {
1718
applyResizeToContainer: true,
1819
calculateAvailableSizeBy: 'window',

packages/common/src/interfaces/column.interface.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ export interface Column<T = any> {
3737
/** async background post-rendering formatter */
3838
asyncPostRender?: (domCellNode: any, row: number, dataContext: T, columnDef: Column) => void;
3939

40+
/**
41+
* Defaults to true, when enabled it will parse the filter input string and extract filter operator (<, <=, >=, >, =, *) when found.
42+
* When an operators is found in the input string, it will automatically be converted to a Filter Operators and will no longer be part of the search value itself.
43+
* For example when the input value is "> 100", it will transform the search as to a Filter Operator of ">" and a search value of "100".
44+
* The only time that the user would want to disable this flag is when the user's data has any of these special characters and the user really wants to filter them as part of the string (ie: >, <, ...)
45+
*/
46+
autoParseInputFilterOperator?: boolean;
47+
4048
/** optional Behavior of a column with action, for example it's used by the Row Move Manager Plugin */
4149
behavior?: string;
4250

packages/common/src/interfaces/gridOption.interface.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@ export interface GridOption {
7878
*/
7979
autoFitColumnsOnFirstLoad?: boolean;
8080

81+
/**
82+
* Defaults to true, when enabled it will parse the filter input string and extract filter operator (<, <=, >=, >, =, *) when found.
83+
* When an operators is found in the input string, it will automatically be converted to a Filter Operators and will no longer be part of the search value itself.
84+
* For example when the input value is "> 100", it will transform the search as to a Filter Operator of ">" and a search value of "100".
85+
* The only time that the user would want to disable this flag is when the user's data has any of these special characters and the user really wants to filter them as part of the string (ie: >, <, ...)
86+
*/
87+
autoParseInputFilterOperator?: boolean;
88+
8189
/**
8290
* Defaults to false, which leads to automatically adjust the width of each column by their cell value content and only on first page/component load.
8391
* If you wish this resize to also re-evaluate when resizing the browser, then you should also use `enableAutoResizeColumnsByCellContent`

packages/common/src/services/__tests__/filter.service.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,21 @@ describe('FilterService', () => {
849849
expect(output).toBe(true);
850850
});
851851

852+
it('should return False when input value has special char "*" substring but "autoParseInputFilterOperator" is set to false so the text "Jo*" will not be found', () => {
853+
const searchTerms = ['Jo*'];
854+
const mockColumn1 = { id: 'firstName', field: 'firstName', filterable: true, autoParseInputFilterOperator: false } as Column;
855+
jest.spyOn(gridStub, 'getColumns').mockReturnValue([mockColumn1]);
856+
857+
service.init(gridStub);
858+
const columnFilter = { columnDef: mockColumn1, columnId: 'firstName', type: FieldType.string };
859+
const filterCondition = service.parseFormInputFilterConditions(searchTerms, columnFilter);
860+
const parsedSearchTerms = getParsedSearchTermsByFieldType(filterCondition.searchTerms, 'text');
861+
const columnFilters = { firstName: { ...columnFilter, operator: filterCondition.operator, searchTerms: filterCondition.searchTerms, parsedSearchTerms } } as ColumnFilters;
862+
const output = service.customLocalFilter(mockItem1, { dataView: dataViewStub, grid: gridStub, columnFilters });
863+
864+
expect(output).toBe(false);
865+
});
866+
852867
it('should return True when input value from datacontext is equal to endsWith substring', () => {
853868
const searchTerms = ['*hn'];
854869
const mockColumn1 = { id: 'firstName', field: 'firstName', filterable: true } as Column;
@@ -892,6 +907,36 @@ describe('FilterService', () => {
892907
expect(output).toBe(true);
893908
});
894909

910+
it('should return True when input value from datacontext contains an operator ">=" and its value is greater than 10', () => {
911+
const searchTerms = ['>=10'];
912+
const mockColumn1 = { id: 'age', field: 'age', filterable: true, autoParseInputFilterOperator: false } as Column;
913+
jest.spyOn(gridStub, 'getColumns').mockReturnValue([mockColumn1]);
914+
915+
service.init(gridStub);
916+
const columnFilter = { columnDef: mockColumn1, columnId: 'age', type: FieldType.number };
917+
const filterCondition = service.parseFormInputFilterConditions(searchTerms, columnFilter);
918+
const parsedSearchTerms = getParsedSearchTermsByFieldType(filterCondition.searchTerms, 'number');
919+
const columnFilters = { age: { ...columnFilter, operator: filterCondition.operator, searchTerms: filterCondition.searchTerms, parsedSearchTerms } } as ColumnFilters;
920+
const output = service.customLocalFilter(mockItem1, { dataView: dataViewStub, grid: gridStub, columnFilters });
921+
922+
expect(output).toBe(true);
923+
});
924+
925+
it('should return False when input value from datacontext contains an operator >= and its value is greater than 10 substring but "autoParseInputFilterOperator" is set to false', () => {
926+
const searchTerms = ['>=10'];
927+
const mockColumn1 = { id: 'age', field: 'age', filterable: true, autoParseInputFilterOperator: false } as Column;
928+
jest.spyOn(gridStub, 'getColumns').mockReturnValue([mockColumn1]);
929+
930+
service.init(gridStub);
931+
const columnFilter = { columnDef: mockColumn1, columnId: 'age', type: FieldType.string };
932+
const filterCondition = service.parseFormInputFilterConditions(searchTerms, columnFilter);
933+
const parsedSearchTerms = getParsedSearchTermsByFieldType(filterCondition.searchTerms, 'string');
934+
const columnFilters = { age: { ...columnFilter, operator: filterCondition.operator, searchTerms: filterCondition.searchTerms, parsedSearchTerms } } as ColumnFilters;
935+
const output = service.customLocalFilter(mockItem1, { dataView: dataViewStub, grid: gridStub, columnFilters });
936+
937+
expect(output).toBe(false);
938+
});
939+
895940
it('should return True when input value is a complex object searchTerms value is found following the dot notation', () => {
896941
const searchTerms = [123456];
897942
const mockColumn1 = { id: 'zip', field: 'zip', filterable: true, queryFieldFilter: 'address.zip', type: FieldType.number } as Column;

packages/common/src/services/filter.service.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,12 @@ export class FilterService {
396396
let matches = null;
397397
if (fieldType !== FieldType.object) {
398398
fieldSearchValue = (fieldSearchValue === undefined || fieldSearchValue === null) ? '' : `${fieldSearchValue}`; // make sure it's a string
399-
matches = fieldSearchValue.match(/^([<>!=\*]{0,2})(.*[^<>!=\*])?([\*]?)$/); // group 1: Operator, 2: searchValue, 3: last char is '*' (meaning starts with, ex.: abc*)
399+
400+
// run regex to find possible filter operators unless the user disabled the feature
401+
const autoParseInputFilterOperator = columnDef.autoParseInputFilterOperator ?? this._gridOptions.autoParseInputFilterOperator;
402+
matches = autoParseInputFilterOperator !== false
403+
? fieldSearchValue.match(/^([<>!=\*]{0,2})(.*[^<>!=\*])?([\*]?)$/) // group 1: Operator, 2: searchValue, 3: last char is '*' (meaning starts with, ex.: abc*)
404+
: [fieldSearchValue, '', fieldSearchValue, '']; // when parsing is disabled, we'll only keep the search value in the index 2 to make it easy for code reuse
400405
}
401406

402407
let operator = matches?.[1] || columnFilter.operator;

packages/graphql/src/services/__tests__/graphql.service.spec.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -744,7 +744,7 @@ describe('GraphqlService', () => {
744744
expect(currentFilters).toEqual([{ columnId: 'gender', operator: 'EQ', searchTerms: ['female'] }]);
745745
});
746746

747-
it('should return a query with search having the operator StartsWith when search value has the * symbol as the last character', () => {
747+
it('should return a query with search having the operator StartsWith when search value has the "*" symbol as the last character', () => {
748748
const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:StartsWith, value:"fem"}]) { totalCount,nodes{ id,company,gender,name } }}`;
749749
const mockColumn = { id: 'gender', field: 'gender' } as Column;
750750
const mockColumnFilters = {
@@ -758,7 +758,7 @@ describe('GraphqlService', () => {
758758
expect(removeSpaces(query)).toBe(removeSpaces(expectation));
759759
});
760760

761-
it('should return a query with search having the operator EndsWith when search value has the * symbol as the first character', () => {
761+
it('should return a query with search having the operator EndsWith when search value has the "*" symbol as the first character', () => {
762762
const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:EndsWith, value:"le"}]) { totalCount,nodes{ id,company,gender,name } }}`;
763763
const mockColumn = { id: 'gender', field: 'gender' } as Column;
764764
const mockColumnFilters = {
@@ -772,7 +772,7 @@ describe('GraphqlService', () => {
772772
expect(removeSpaces(query)).toBe(removeSpaces(expectation));
773773
});
774774

775-
it('should return a query with search having the operator EndsWith when the operator was provided as *z', () => {
775+
it('should return a query with search having the operator EndsWith when the operator was provided as "*z"', () => {
776776
const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:EndsWith, value:"le"}]) { totalCount,nodes{ id,company,gender,name } }}`;
777777
const mockColumn = { id: 'gender', field: 'gender' } as Column;
778778
const mockColumnFilters = {
@@ -786,7 +786,7 @@ describe('GraphqlService', () => {
786786
expect(removeSpaces(query)).toBe(removeSpaces(expectation));
787787
});
788788

789-
it('should return a query with search having the operator StartsWith even when search value last char is * symbol but the operator provided is *z', () => {
789+
it('should return a query with search having the operator StartsWith even when search value last char is "*" symbol but the operator provided is "*z"', () => {
790790
const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:StartsWith, value:"le"}]) { totalCount,nodes{ id,company,gender,name } }}`;
791791
const mockColumn = { id: 'gender', field: 'gender' } as Column;
792792
const mockColumnFilters = {
@@ -800,7 +800,7 @@ describe('GraphqlService', () => {
800800
expect(removeSpaces(query)).toBe(removeSpaces(expectation));
801801
});
802802

803-
it('should return a query with search having the operator EndsWith when the Column Filter was provided as *z', () => {
803+
it('should return a query with search having the operator EndsWith when the Column Filter was provided as "*z"', () => {
804804
const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:EndsWith, value:"le"}]) { totalCount,nodes{ id,company,gender,name } }}`;
805805
const mockColumn = { id: 'gender', field: 'gender', filter: { operator: '*z' } } as Column;
806806
const mockColumnFilters = {
@@ -828,7 +828,7 @@ describe('GraphqlService', () => {
828828
expect(removeSpaces(query)).toBe(removeSpaces(expectation));
829829
});
830830

831-
it('should return a query with search having the operator StartsWith when the operator was provided as a*', () => {
831+
it('should return a query with search having the operator StartsWith when the operator was provided as "a*"', () => {
832832
const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:StartsWith, value:"le"}]) { totalCount,nodes{ id,company,gender,name } }}`;
833833
const mockColumn = { id: 'gender', field: 'gender' } as Column;
834834
const mockColumnFilters = {
@@ -856,6 +856,34 @@ describe('GraphqlService', () => {
856856
expect(removeSpaces(query)).toBe(removeSpaces(expectation));
857857
});
858858

859+
it('should return a query with search having the operator Greater of Equal when the search value was provided as ">=10"', () => {
860+
const expectation = `query{users(first:10, offset:0, filterBy:[{field:age, operator:GE, value:"10"}]) { totalCount,nodes{ id,company,gender,name } }}`;
861+
const mockColumn = { id: 'age', field: 'age' } as Column;
862+
const mockColumnFilters = {
863+
age: { columnId: 'age', columnDef: mockColumn, searchTerms: ['>=10'], type: FieldType.string },
864+
} as ColumnFilters;
865+
866+
service.init(serviceOptions, paginationOptions, gridStub);
867+
service.updateFilters(mockColumnFilters, false);
868+
const query = service.buildQuery();
869+
870+
expect(removeSpaces(query)).toBe(removeSpaces(expectation));
871+
});
872+
873+
it('should return a query with search NOT having the operator Greater of Equal when the search value was provided as ">=10" but "autoParseInputFilterOperator" is set to false', () => {
874+
const expectation = `query{users(first:10, offset:0, filterBy:[{field:age, operator:Contains, value:">=10"}]) { totalCount,nodes{ id,company,gender,name } }}`;
875+
const mockColumn = { id: 'age', field: 'age', autoParseInputFilterOperator: false } as Column;
876+
const mockColumnFilters = {
877+
age: { columnId: 'age', columnDef: mockColumn, searchTerms: ['>=10'], type: FieldType.string },
878+
} as ColumnFilters;
879+
880+
service.init(serviceOptions, paginationOptions, gridStub);
881+
service.updateFilters(mockColumnFilters, false);
882+
const query = service.buildQuery();
883+
884+
expect(removeSpaces(query)).toBe(removeSpaces(expectation));
885+
});
886+
859887
it('should return a query with search having a range of exclusive numbers when the search value contains 2 dots (..) to represent a range of numbers', () => {
860888
const expectation = `query{users(first:10, offset:0, filterBy:[{field:duration, operator:GE, value:"2"}, {field:duration, operator:LE, value:"33"}]) { totalCount,nodes{ id,company,gender,name } }}`;
861889
const mockColumn = { id: 'duration', field: 'duration' } as Column;

packages/graphql/src/services/graphql.service.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -406,10 +406,16 @@ export class GraphqlService implements BackendService {
406406
}
407407

408408
fieldSearchValue = (fieldSearchValue === undefined || fieldSearchValue === null) ? '' : `${fieldSearchValue}`; // make sure it's a string
409-
const matches = fieldSearchValue.match(/^([<>!=\*]{0,2})(.*[^<>!=\*])([\*]?)$/); // group 1: Operator, 2: searchValue, 3: last char is '*' (meaning starts with, ex.: abc*)
410-
let operator: OperatorString = columnFilter.operator || ((matches) ? matches[1] : '');
411-
searchValue = (!!matches) ? matches[2] : '';
412-
const lastValueChar = (!!matches) ? matches[3] : (operator === '*z' ? '*' : '');
409+
410+
// run regex to find possible filter operators unless the user disabled the feature
411+
const autoParseInputFilterOperator = columnDef.autoParseInputFilterOperator ?? this._gridOptions.autoParseInputFilterOperator;
412+
const matches = autoParseInputFilterOperator !== false
413+
? fieldSearchValue.match(/^([<>!=\*]{0,2})(.*[^<>!=\*])([\*]?)$/) // group 1: Operator, 2: searchValue, 3: last char is '*' (meaning starts with, ex.: abc*)
414+
: [fieldSearchValue, '', fieldSearchValue, '']; // when parsing is disabled, we'll only keep the search value in the index 2 to make it easy for code reuse
415+
416+
let operator: OperatorString = columnFilter.operator || matches?.[1] || '';
417+
searchValue = matches?.[2] || '';
418+
const lastValueChar = matches?.[3] || (operator === '*z' ? '*' : '');
413419

414420
// no need to query if search value is empty
415421
if (fieldName && searchValue === '' && searchTerms.length === 0) {

0 commit comments

Comments
 (0)