Skip to content

Commit

Permalink
feat(filters): add a filterPredicate option for user customization (#…
Browse files Browse the repository at this point in the history
…1528)

* feat(filters): add a `filterPredicate` option for user customization
- adding a `filterPredicate` would help user who want to have a custom filter predicate callback that cannot be handled by built-in filters
- also added a patch filter version, that was only for testing purpose (will probably remove before merging) and to provide a way to answer the SO question: https://stackoverflow.com/questions/78471412/angular-slickgrid-filter
  • Loading branch information
ghiscoding committed May 14, 2024
1 parent 4284d58 commit cbf64d8
Show file tree
Hide file tree
Showing 14 changed files with 199 additions and 50 deletions.
12 changes: 6 additions & 6 deletions docs/TOC.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@
* [LongText (textarea)](column-functionalities/editors/LongText-Editor-(textarea).md)
* [Select Dropdown Editor (single/multiple)](column-functionalities/editors/Select-Dropdown-Editor-(single,multiple).md)
* [Filters](column-functionalities/filters/README.md)
* [Input Filter (default)](column-functionalities/filters/Input-Filter.md)
* [Select Filter (dropdown)](column-functionalities/filters/Select-Filter.md)
* [Compound Filters](column-functionalities/filters/Compound-Filters.md)
* [Range Filters](column-functionalities/filters/Range-Filters.md)
* [Styling Filled Filters](column-functionalities/filters/Styling-Filled-Filters.md)
* [Single Search Filter](column-functionalities/filters/Single-Search-Filter.md)
* [Input Filter (default)](column-functionalities/filters/input-filter.md)
* [Select Filter (dropdown)](column-functionalities/filters/select-filter.md)
* [Compound Filters](column-functionalities/filters/compound-filters.md)
* [Range Filters](column-functionalities/filters/range-filters.md)
* [Styling Filled Filters](column-functionalities/filters/styling-filled-filters.md)
* [Single Search Filter](column-functionalities/filters/single-search-filter.md)
* [Formatters](column-functionalities/Formatters.md)
* [Sorting](column-functionalities/Sorting.md)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
- [Compound Date Filter](#how-to-use-compounddate-filter)
- [Compound Operator List (custom list)](#compound-operator-list-custom-list)
- [Compound Operator Alternate Texts](#compound-operator-alternate-texts)
- [Filter Complex Object](../Input-Filter.md#how-to-filter-complex-objects)
- [Update Filters Dynamically](../Input-Filter.md#update-filters-dynamically)
- [Filter Complex Object](input-filter.md#how-to-filter-complex-objects)
- [Update Filters Dynamically](input-filter.md#update-filters-dynamically)
- [How to avoid filtering when only Operator dropdown is changed?](#how-to-avoid-filtering-when-only-operator-dropdown-is-changed)
- [Custom Filter Predicate](input-filter.md#custom-filter-predicate)

### Description
Compound filters are a combination of 2 elements (Operator Select + Input Filter) used as a filter on a column. This is very useful to make it obvious to the user that there are Operator available and even more useful with a date picker (`Vanilla-Calendar`).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- [Dynamic Query Field](#dynamic-query-field)
- [Debounce/Throttle Text Search (wait for user to stop typing before filtering)](#debouncethrottle-text-search-wait-for-user-to-stop-typing-before-filtering)
- [Ignore Locale Accent in Text Filter/Sorting](#ignore-locale-accent-in-text-filtersorting)
- [Custom Filter Predicate](#custom-filter-predicate)

### Description
Input filter is the default filter when enabling filters.
Expand Down Expand Up @@ -167,4 +168,46 @@ You can ignore latin accent (or any other language accent) in text filter via th
this.gridOptions = {
ignoreAccentOnStringFilterAndSort: true,
};
```
```

### Custom Filter Predicate
You can provide a custom predicate by using the `filterPredicate` when defining your `filter`, the callback will provide you with 2 arguments (`dataContext` and `searchFilterArgs`). The `searchFilterArgs` will provide you more info about the filter itself (like parsed operator, search terms, column definition, column id and type as well). For example

```ts
this.columnDefinitions = [
{
id: 'title', name: 'Title', field: 'title', sortable: true,
filterable: true, type: FieldType.string,
filter: {
model: Filters.inputText,
// you can use your own custom filter predicate when built-in filters aren't working for you
// for example the example below will function similarly to an SQL LIKE to answer this SO: https://stackoverflow.com/questions/78471412/angular-slickgrid-filter
filterPredicate: (dataContext, searchFilterArgs) => {
const searchVals = (searchFilterArgs.parsedSearchTerms || []) as SearchTerm[];
if (searchVals?.length) {
const columnId = searchFilterArgs.columnId;
const searchVal = searchVals[0] as string;
const likeMatches = searchVal.split('%');
if (likeMatches.length > 3) {
// for matches like "%Ta%10%" will return text that starts with "Ta" and ends with "10" (e.g. "Task 10", "Task 110", "Task 210")
const [_, start, end] = likeMatches;
return dataContext[columnId].startsWith(start) && dataContext[columnId].endsWith(end);
} else if (likeMatches.length > 2) {
// for matches like "%Ta%10%" will return text that starts with "Ta" and contains "10" (e.g. "Task 10", "Task 100", "Task 101")
const [_, start, contain] = likeMatches;
return dataContext[columnId].startsWith(start) && dataContext[columnId].includes(contain);
}
// anything else is also a contains
return dataContext[columnId].includes(searchVal);
}
// if we fall here then the value is not consider to be filtered out
return true;
},
},
},
];
```

The custom filter predicate above was to answer a Stack Overflow question and will work like an SQL LIKE matcher (it's not perfect and probably requires more work but is enough to demo custom predicate)

![image](https://github.com/ghiscoding/slickgrid-universal/assets/643976/3e77774e-3a9f-4ca4-bca7-50a033a4b48d)
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
- [Using a Slider Range](#using-a-slider-range-filter)
- [Filter Options](#filter-options)
- [Using a Date Range](#using-a-date-range-filter)
- [Update Filters Dynamically](Input-Filter.md#update-filters-dynamically)
- [Update Filters Dynamically](input-filter.md#update-filters-dynamically)
- [Custom Filter Predicate](input-filter.md#custom-filter-predicate)

### Introduction
Range filters allows you to search for a value between 2 min/max values, the 2 most common use case would be to filter between 2 numbers or dates, you can do that with the Slider & Date Range Filters. The range can also be defined as inclusive (`>= 0 and <= 10`) or exclusive (`> 0 and < 10`), the default is exclusive but you can change that, see below for more info.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
- [Filter Options (`MultipleSelectOption` interface)](#filter-options-multipleselectoption-interface)
- [Display shorter selected label text](#display-shorter-selected-label-text)
- [Query against a different field](#query-against-another-field-property)
- [Update Filters Dynamically](Input-Filter.md#update-filters-dynamically)
- [Update Filters Dynamically](input-filter.md#update-filters-dynamically)
- [Custom Filter Predicate](input-filter.md#custom-filter-predicate)

### Demo
[Demo Page](https://ghiscoding.github.io/slickgrid-universal/#/example10) / [Demo Component](https://github.com/ghiscoding/slickgrid-universal/blob/master/examples/webpack-demo-vanilla-bundle/src/examples/example10.ts)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
#### Index
- [Update Filters Dynamically](Input-Filter.md#update-filters-dynamically)
- [Update Filters Dynamically](input-filter.md#update-filters-dynamically)
- [Custom Filter Predicate](input-filter.md#custom-filter-predicate)

### Description
Some users might want to have 1 main single search for filtering the grid data instead of using multiple column filters. You can see a demo of that below
Some users might want to have 1 main single search for filtering the grid data instead of using multiple column filters. You can see a demo of that below.

> **Note** the code below came from a different SlickGrid framework, just change the `.bind` to whatever framework you use with the appropriate code change. It's only meant to show roughly how to do it.
### Code Sample
#### View
Expand Down Expand Up @@ -33,19 +36,19 @@ Some users might want to have 1 main single search for filtering the grid data i
value.bind="searchValue">
</form>

<aurelia-slickgrid grid-id="grid21"
column-definitions.bind="columnDefinitions"
grid-options.bind="gridOptions"
dataset.bind="dataset">
</aurelia-slickgrid>
<div grid-id="grid21"
column-definitions.bind="columnDefinitions"
grid-options.bind="gridOptions"
dataset.bind="dataset">
</div>
```

##### ViewModel
```ts
export class MyExample {
@bindable() selectedColumn: Column;
@bindable() selectedOperator: string;
@bindable() searchValue: string;
selectedColumn: Column;
selectedOperator: string;
searchValue: string;

grid: SlickGrid;
dataView: SlickDataView;
Expand Down
30 changes: 28 additions & 2 deletions examples/vite-demo-vanilla-bundle/src/examples/example14.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type GridOption,
type GridStateChange,
type LongTextEditorOption,
type SearchTerm,
SlickGlobalEditorLock,
type SliderRangeOption,
SortComparers,
Expand Down Expand Up @@ -149,7 +150,32 @@ export default class Example14 {
resizeCalcWidthRatio: 1, // default ratio is ~0.9 for string but since our text is all uppercase then a higher ratio is needed
resizeMaxWidthThreshold: 200,
filterable: true, columnGroup: 'Common Factor',
filter: { model: Filters.compoundInputText },
filter: {
model: Filters.inputText,
// you can use your own custom filter predicate when built-in filters aren't working for you
// for example the example below will function similarly to an SQL LIKE to answer this SO: https://stackoverflow.com/questions/78471412/angular-slickgrid-filter
filterPredicate: (dataContext, searchFilterArgs) => {
const searchVals = (searchFilterArgs.parsedSearchTerms || []) as SearchTerm[];
if (searchVals?.length) {
const columnId = searchFilterArgs.columnId;
const searchVal = searchVals[0] as string;
const likeMatches = searchVal.split('%');
if (likeMatches.length > 3) {
// for matches like "%Ta%10%" will return text that starts with "Ta" and ends with "10" (e.g. "Task 10", "Task 110", "Task 210")
const [_, start, end] = likeMatches;
return dataContext[columnId].startsWith(start) && dataContext[columnId].endsWith(end);
} else if (likeMatches.length > 2) {
// for matches like "%Ta%10%" will return text that starts with "Ta" and contains "10" (e.g. "Task 10", "Task 100", "Task 101")
const [_, start, contain] = likeMatches;
return dataContext[columnId].startsWith(start) && dataContext[columnId].includes(contain);
}
// anything else is also a contains
return dataContext[columnId].includes(searchVal);
}
// if we fall here then the value is not consider to be filtered out
return true;
},
},
editor: {
model: Editors.longText, required: true, alwaysSaveOnEnterKey: true,
maxLength: 12,
Expand Down Expand Up @@ -288,7 +314,7 @@ export default class Example14 {
},
filter: {
model: Filters.inputText,
// placeholder: '🔎︎ search city',
// placeholder: '🔎︎ search product',
type: FieldType.string,
queryField: 'product.itemName',
}
Expand Down
2 changes: 1 addition & 1 deletion packages/common/src/interfaces/columnEditor.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export interface ColumnEditor {
*/
collection?: any[];

/** We could filter some 1 or more items from the collection */
/** We could filter 1 or more items from the collection (e.g. filter out some items from the select filter) */
collectionFilterBy?: CollectionFilterBy | CollectionFilterBy[];

/** Options to change the behavior of the "collection" */
Expand Down
9 changes: 8 additions & 1 deletion packages/common/src/interfaces/columnFilter.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
Filter,
FilterConstructor,
OperatorDetail,
SearchColumnFilter,
} from './index';
import type { Observable, Subject } from '../services/rxjsFacade';

Expand Down Expand Up @@ -57,7 +58,7 @@ export interface ColumnFilter {
/** Options to change the behavior of the "collection" */
collectionOptions?: CollectionOption;

/** We could filter some 1 or more items from the collection */
/** We could filter 1 or more items from the collection (e.g. filter out some items from the select filter) */
collectionFilterBy?: CollectionFilterBy | CollectionFilterBy[];

/** We could sort the collection by 1 or more properties, or by translated value(s) when enableTranslateLabel is True */
Expand Down Expand Up @@ -94,6 +95,12 @@ export interface ColumnFilter {
*/
filterOptions?: any;

/**
* Custom Filter predicate function to use instead of the built-in filters
* NOTE: currently only works with local/JSON dataset, meaning no backend service implementation yet.
*/
filterPredicate?: (dataContext: any, searchFilterArgs: SearchColumnFilter) => boolean;

/**
* Use "params" to pass any type of arguments to your Custom Filter
* for example, to pass a second collection to a select Filter we can type this:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ export interface SearchColumnFilter {
/** Last search input character when it is identified as "*" representing startsWith */
searchInputLastChar?: string;

/** What is the Field Type that can be used by the Filter (as precedence over the "type" set the column definition) */
/** What is the Field Type that can be used by the Filter (as precedence over the "type" defined in the column definition) */
type: typeof FieldType[keyof typeof FieldType];

/** Target element selector from which the filter was triggered from. */
/** DOM target element selector from which the filter was triggered from. */
targetSelector?: string;
}
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
@@ -1,3 +1,4 @@
import 'jest-extended';
import { of, throwError } from 'rxjs';
import { BasePubSubService } from '@slickgrid-universal/event-pub-sub';

Expand Down Expand Up @@ -663,6 +664,50 @@ describe('FilterService', () => {
mockItem1 = { firstName: 'John', lastName: 'Doe', fullName: 'John Doe', age: 26, address: { zip: 123456 } };
});

it('should run "filterPredicate" when provided by the user as a custom filter callback and return True when predicate returns true', () => {
const columnId = 'firstName';
const searchTerms = ['John'];
const mockColumn1 = {
id: columnId, field: columnId, filterable: true,
filter: {
model: Filters.inputText,
filterPredicate: (dataContext, searchFilterArgs) => {
return dataContext[columnId] === searchFilterArgs.parsedSearchTerms![0];
}
}
} as Column;
jest.spyOn(gridStub, 'getColumns').mockReturnValue([mockColumn1]);

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

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

it('should run "filterPredicate" when provided by the user as a custom filter callback and return False when predicate returns false', () => {
const columnId = 'firstName';
const searchTerms = ['JANE'];
const mockColumn1 = {
id: columnId, field: columnId, filterable: true,
filter: {
model: Filters.inputText,
filterPredicate: (dataContext, searchFilterArgs) => {
return dataContext[columnId] === searchFilterArgs.parsedSearchTerms![0];
}
}
} as Column;
jest.spyOn(gridStub, 'getColumns').mockReturnValue([mockColumn1]);

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

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

it('should execute "getParsedSearchTermsByFieldType" once if the first "customLocalFilter" is executed without parsedSearchTerms at the beginning', () => {
const searchTerms = ['John'];
const mockColumn1 = { id: 'firstName', field: 'firstName', filterable: true } as Column;
Expand Down
Loading

0 comments on commit cbf64d8

Please sign in to comment.