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 a filterPredicate option for user customization #1528

Merged
merged 3 commits into from
May 14, 2024
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
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
Loading