Skip to content

Commit

Permalink
feat(filters): add StartsWith/EndsWith (a*z) filter combo (#1530)
Browse files Browse the repository at this point in the history
* feat(filters): add StartsWith/EndsWith (`a*z`) filter combo
  • Loading branch information
ghiscoding committed May 16, 2024
1 parent 27777ef commit 51560aa
Show file tree
Hide file tree
Showing 8 changed files with 220 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export default class Example02 {
initializeGrid() {
this.columnDefinitions = [
{
id: 'sel', name: '#', field: 'num', width: 40,
id: 'sel', name: '#', field: 'num', width: 40, type: FieldType.number,
excludeFromExport: true,
maxWidth: 70,
resizable: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ export default class Example21 {
}

dispose() {
console.log('dispose');
this._eventHandler.unsubscribeAll();
this._bindingEventService.unbindAll();
this.sgb?.dispose();
Expand Down
39 changes: 38 additions & 1 deletion packages/common/src/enums/operatorString.type.ts
Original file line number Diff line number Diff line change
@@ -1 +1,38 @@
export type OperatorString = '' | '<>' | '!=' | '=' | '==' | '>' | '>=' | '<' | '<=' | '*' | 'Custom' | 'a*' | '*z' | 'EQ' | 'GE' | 'GT' | 'NE' | 'LE' | 'LT' | 'IN' | 'NIN' | 'NOT_IN' | 'IN_CONTAINS' | 'NIN_CONTAINS' | 'NOT_IN_CONTAINS' | 'NOT_CONTAINS' | 'Not_Contains' | 'CONTAINS' | 'Contains' | 'EndsWith' | 'StartsWith' | 'RangeInclusive' | 'RangeExclusive' | 'IN_COLLECTION' | 'NOT_IN_COLLECTION';
export type OperatorString =
| ''
| '<>'
| '!='
| '='
| '=='
| '>'
| '>='
| '<'
| '<='
| '*'
| 'a*'
| '*z'
| 'a*z'
| 'Custom'
| 'EQ'
| 'GE'
| 'GT'
| 'NE'
| 'LE'
| 'LT'
| 'IN'
| 'NIN'
| 'NOT_IN'
| 'IN_CONTAINS'
| 'NIN_CONTAINS'
| 'NOT_IN_CONTAINS'
| 'NOT_CONTAINS'
| 'Not_Contains'
| 'CONTAINS'
| 'Contains'
| 'EndsWith'
| 'StartsWith'
| 'StartsWithEndsWith'
| 'RangeInclusive'
| 'RangeExclusive'
| 'IN_COLLECTION'
| 'NOT_IN_COLLECTION';
3 changes: 3 additions & 0 deletions packages/common/src/enums/operatorType.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export enum OperatorType {
/** String starts with value */
startsWith = 'StartsWith',

/** Combo Starts With A + Ends With Z */
startsWithEndsWith = 'StartsWithEndsWith',

/** Find an equal match inside a collection */
in = 'IN',

Expand Down
11 changes: 9 additions & 2 deletions packages/common/src/filter-conditions/stringFilterCondition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ export const executeStringFilterCondition: FilterCondition = ((options: FilterCo
searchValue2 = options?.ignoreAccentOnStringFilterAndSort ? removeAccentFromText(searchValue2, true) : searchValue2.toLowerCase();
}

if (searchValue1 !== undefined && searchValue2 !== undefined) {
if (options.operator === OperatorType.startsWithEndsWith && searchValue1 !== undefined && searchValue2 !== undefined) {
return testStartsWithEndsWith(cellValue, [searchValue1, searchValue2]);
} else if (searchValue1 !== undefined && searchValue2 !== undefined) {
let operator = options?.operator ?? options.defaultFilterRangeOperator;
if (operator !== OperatorType.rangeInclusive && operator !== OperatorType.rangeExclusive) {
operator = options.defaultFilterRangeOperator;
Expand Down Expand Up @@ -77,4 +79,9 @@ function testStringCondition(operator: OperatorType | OperatorString, cellValue:
return (cellValue.indexOf(searchValue) === -1);
}
return testFilterCondition(operator || '==', cellValue, searchValue);
}
}

/** Execute the filter string test condition that starts with A and ends with B */
function testStartsWithEndsWith(cellValue: string, [startW, endW]: [string, string]): boolean {
return cellValue.startsWith(startW) && cellValue.endsWith(endW);
}
45 changes: 45 additions & 0 deletions packages/common/src/services/__tests__/filter.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,51 @@ describe('FilterService', () => {
expect(output).toBe(true);
});

it('should return True when input value from datacontext is equal to startsWith (1x)char + endsWith (1x)char', () => {
const searchTerms = ['J*n'];
const mockColumn1 = { id: 'firstName', field: 'firstName', filterable: true } as Column;
jest.spyOn(gridStub, 'getColumns').mockReturnValue([mockColumn1]);

service.init(gridStub);
const columnFilter = { columnDef: mockColumn1, columnId: 'firstName', type: FieldType.string };
const filterCondition = service.parseFormInputFilterConditions(searchTerms, columnFilter);
const parsedSearchTerms = getParsedSearchTermsByFieldType(filterCondition.searchTerms, 'text');
const columnFilters = { firstName: { ...columnFilter, operator: filterCondition.operator, searchTerms: filterCondition.searchTerms, parsedSearchTerms } } as ColumnFilters;
const output = service.customLocalFilter(mockItem1, { dataView: dataViewStub, grid: gridStub, columnFilters });

expect(output).toBe(true);
});

it('should return True when input value from datacontext is equal to startsWith substring + endsWith substring', () => {
const searchTerms = ['Jo*hn'];
const mockColumn1 = { id: 'firstName', field: 'firstName', filterable: true } as Column;
jest.spyOn(gridStub, 'getColumns').mockReturnValue([mockColumn1]);

service.init(gridStub);
const columnFilter = { columnDef: mockColumn1, columnId: 'firstName', type: FieldType.string };
const filterCondition = service.parseFormInputFilterConditions(searchTerms, columnFilter);
const parsedSearchTerms = getParsedSearchTermsByFieldType(filterCondition.searchTerms, 'text');
const columnFilters = { firstName: { ...columnFilter, operator: filterCondition.operator, searchTerms: filterCondition.searchTerms, parsedSearchTerms } } as ColumnFilters;
const output = service.customLocalFilter(mockItem1, { dataView: dataViewStub, grid: gridStub, columnFilters });

expect(output).toBe(true);
});

it('should return False when input value from datacontext does NOT equal both startsWith substring + endsWith substring', () => {
const searchTerms = ['J*nee'];
const mockColumn1 = { id: 'firstName', field: 'firstName', filterable: true } as Column;
jest.spyOn(gridStub, 'getColumns').mockReturnValue([mockColumn1]);

service.init(gridStub);
const columnFilter = { columnDef: mockColumn1, columnId: 'firstName', type: FieldType.string };
const filterCondition = service.parseFormInputFilterConditions(searchTerms, columnFilter);
const parsedSearchTerms = getParsedSearchTermsByFieldType(filterCondition.searchTerms, 'text');
const columnFilters = { firstName: { ...columnFilter, operator: filterCondition.operator, searchTerms: filterCondition.searchTerms, parsedSearchTerms } } as ColumnFilters;
const output = service.customLocalFilter(mockItem1, { dataView: dataViewStub, grid: gridStub, columnFilters });

expect(output).toBe(false);
});

it('should return True when input value from datacontext is equal to startsWith substring when using Operator startsWith', () => {
const searchTerms = ['Jo'];
const operator = 'a*';
Expand Down
21 changes: 14 additions & 7 deletions packages/common/src/services/filter.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ export class FilterService {
* @returns FilterConditionOption
*/
parseFormInputFilterConditions(inputSearchTerms: SearchTerm[] | undefined, columnFilter: Omit<SearchColumnFilter, 'searchTerms'>): Omit<FilterConditionOption, 'cellValue'> {
const searchValues: SearchTerm[] = extend(true, [], inputSearchTerms) || [];
let searchValues: SearchTerm[] = extend(true, [], inputSearchTerms) || [];
let fieldSearchValue = (Array.isArray(searchValues) && searchValues.length === 1) ? searchValues[0] : '';
const columnDef = columnFilter.columnDef;
const fieldType = columnDef.filter?.type ?? columnDef.type ?? FieldType.string;
Expand All @@ -425,18 +425,25 @@ export class FilterService {

// run regex to find possible filter operators unless the user disabled the feature
const autoParseInputFilterOperator = columnDef.autoParseInputFilterOperator ?? this._gridOptions.autoParseInputFilterOperator;

// group (1): comboStartsWith, (2): comboEndsWith, (3): Operator, (4): searchValue, (5): last char is '*' (meaning starts with, ex.: abc*)
matches = autoParseInputFilterOperator !== false
? fieldSearchValue.match(/^([<>!=*]{0,2})(.*[^<>!=*])?([*]?)$/) // group 1: Operator, 2: searchValue, 3: last char is '*' (meaning starts with, ex.: abc*)
: [fieldSearchValue, '', fieldSearchValue, '']; // when parsing is disabled, we'll only keep the search value in the index 2 to make it easy for code reuse
? fieldSearchValue.match(/^(.*[^\\*\r\n])[*]{1}(.*[^*\r\n])|^([<>!=*]{0,2})(.*[^<>!=*])([*]?)$/) || []
: [fieldSearchValue, '', '', '', fieldSearchValue, ''];
}

let operator = matches?.[1] || columnFilter.operator;
const searchTerm = matches?.[2] || '';
const inputLastChar = matches?.[3] || (operator === '*z' ? '*' : '');
const comboStartsWith = matches?.[1] || '';
const comboEndsWith = matches?.[2] || '';
let operator = matches?.[3] || columnFilter.operator;
const searchTerm = matches?.[4] || '';
const inputLastChar = matches?.[5] || (operator === '*z' ? '*' : '');

if (typeof fieldSearchValue === 'string') {
fieldSearchValue = fieldSearchValue.replace(`'`, `''`); // escape any single quotes by doubling them
if (operator === '*' || operator === '*z') {
if (comboStartsWith && comboEndsWith) {
searchValues = [comboStartsWith, comboEndsWith];
operator = OperatorType.startsWithEndsWith;
} else if (operator === '*' || operator === '*z') {
operator = OperatorType.endsWith;
} else if (operator === 'a*' || inputLastChar === '*') {
operator = OperatorType.startsWith;
Expand Down
110 changes: 110 additions & 0 deletions test/cypress/e2e/example02.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,114 @@ describe('Example 02 - Grouping & Aggregators', () => {
.find('.slick-cell:nth(3)').contains('Avg: ');
});
});

describe('Diverse Input Text Filters with multiple symbol variances', () => {
it('should clear all Groupings', () => {
cy.get('[data-test="clear-grouping-btn"]').click();
});

it('should return 500 rows using "Ta*33" (starts with "Ta" + ends with 33)', () => {
cy.get('.search-filter.filter-title')
.clear()
.type('Ta*3');

cy.get('.item-count')
.should('contain', 5000);


cy.get('.search-filter.filter-title')
.clear()
.type('Ta*33');

cy.get('.item-count')
.should('contain', 500);

cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 33');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 133');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 233');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 333');
});

it('should return 40000 rows using "Ta*" (starts with "Ta")', () => {
cy.get('.search-filter.filter-title')
.clear()
.type('Ta*');

cy.get('.item-count')
.should('contain', 40000);

cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 1');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 2');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 3');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 4');
});

it('should return 500 rows using "*11" (ends with "11")', () => {
cy.get('.search-filter.filter-title')
.clear()
.type('*11');

cy.get('.item-count')
.should('contain', 500);

cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 1');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 11');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 21');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 31');
});

it('should return 497 rows using ">222" (greater than 222)', () => {
cy.get('.search-filter.filter-sel')
.clear()
.type('>222');

cy.get('.item-count')
.should('contain', 497);

cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 311');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 411');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 511');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 611');
});

it('should return 499 rows using "<>311" (not equal to 311)', () => {
cy.get('.search-filter.filter-sel')
.clear()
.type('<>311');

cy.get('.item-count')
.should('contain', 499);

cy.get('.search-filter.filter-sel')
.clear()
.type('!=311');

cy.get('.item-count')
.should('contain', 499);

cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 11');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 111');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 211');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 411');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 511');
});

it('should return 1 rows using "=311" or "==311" (equal to 311)', () => {
cy.get('.search-filter.filter-sel')
.clear()
.type('=311');

cy.get('.item-count')
.should('contain', 1);

cy.get('.search-filter.filter-sel')
.clear()
.type('==311');

cy.get('.item-count')
.should('contain', 1);

cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 311');
});
});
});

0 comments on commit 51560aa

Please sign in to comment.