diff --git a/docs/backend-services/graphql/GraphQL-Filtering.md b/docs/backend-services/graphql/GraphQL-Filtering.md index bc9fb3f41..70cdca618 100644 --- a/docs/backend-services/graphql/GraphQL-Filtering.md +++ b/docs/backend-services/graphql/GraphQL-Filtering.md @@ -2,6 +2,7 @@ - [filterBy](#filterby) - [Complex Objects](#complex-objects) - [Extra Query Arguments](#extra-query-arguments) +- [Override the filter query](#override-the-filter-query) ### Introduction The implementation of a GraphQL Service requires a certain structure to follow for `Slickgrid-Universal` to work correctly (it will fail if your GraphQL Schema is any different than what is shown below). @@ -131,4 +132,25 @@ this.gridOptions = { } } } +``` + +### Override the filter query + +Column filters may have a `Custom` Operator, that acts as a placeholder for you to define your own logic. To do so, the easiest way is to provide the `filterQueryOverride` callback in the GraphQL Options. This method will be called with `BackendServiceFilterQueryOverrideArgs` to let you decide dynamically on how the filter should be assembled. + +E.g. you could listen for a specific column and the active `OperatorType.custom` in order to switch the filter to an SQL LIKE search in GraphQL: + +> **Note** technically speaking GraphQL isn't a database query language like SQL, it's an application query language. Depending on your configuration, your GraphQL Server might already support regex querying (e.g. Hasura [_regex](https://hasura.io/docs/latest/queries/postgres/filters/text-search-operators/#_regex)) or you could add your own implementation (e.g. see this SO: https://stackoverflow.com/a/37981802/1212166). Just make sure that whatever custom operator that you want to use, is already included in your GraphQL Schema. + +```ts +backendServiceApi: { + options: { + filterQueryOverride: ({ fieldName, columnDef, columnFilterOperator, searchValue }) => { + if (columnFilterOperator === OperatorType.custom && columnDef?.id === 'name') { + // the `operator` is a string, make sure to implement this new operator in your GraphQL Schema + return { field: fieldName, operator: 'Like', value: searchValue }; + } + }, + } +} ``` \ No newline at end of file diff --git a/docs/grid-functionalities/Export-to-Excel.md b/docs/grid-functionalities/Export-to-Excel.md index 95d94a059..ce88437a7 100644 --- a/docs/grid-functionalities/Export-to-Excel.md +++ b/docs/grid-functionalities/Export-to-Excel.md @@ -173,9 +173,9 @@ export class MyExample { // push empty data on A1 cols.push({ value: '' }); // push data in B1 cell with metadata formatter - cols.push({ - value: customTitle, - metadata: { style: excelFormat.id } + cols.push({ + value: customTitle, + metadata: { style: excelFormat.id } }); sheet.data.push(cols); } @@ -315,7 +315,6 @@ Below is a preview of the previous customizations shown above ![image](https://user-images.githubusercontent.com/643976/208590003-b637dcda-5164-42cc-bfad-e921a22c1837.png) - ### Cell Format Auto-Detect Disable ##### requires `v3.2.0` or higher The system will auto-detect the Excel format to use for Date and Number field types, if for some reason you wish to disable it then you provide the excel export options below diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example10.html b/examples/vite-demo-vanilla-bundle/src/examples/example10.html index 1d9495c7b..940cf169e 100644 --- a/examples/vite-demo-vanilla-bundle/src/examples/example10.html +++ b/examples/vite-demo-vanilla-bundle/src/examples/example10.html @@ -17,7 +17,10 @@

(*) NO DATA SHOWN - - just change any of Filters/Sorting/Pages and look at the "GraphQL Query" changing :) + - just change any of Filters/Sorting/Pages and look at the "GraphQL Query" changing. + Also note that the column Name has a filter with a custom %% operator that behaves like an SQL LIKE operator supporting % wildcards. + Depending on your configuration, your GraphQL Server might already support regex querying (e.g. Hasura [_regex](https://hasura.io/docs/latest/queries/postgres/filters/text-search-operators/#_regex)) + or you could add your own implementation (e.g. see this SO: https://stackoverflow.com/a/37981802/1212166).
diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example10.ts b/examples/vite-demo-vanilla-bundle/src/examples/example10.ts index ac2e39856..7d8eb5548 100644 --- a/examples/vite-demo-vanilla-bundle/src/examples/example10.ts +++ b/examples/vite-demo-vanilla-bundle/src/examples/example10.ts @@ -83,7 +83,15 @@ export default class Example10 { sortable: true, filterable: true, filter: { - model: Filters.compoundInput + model: Filters.compoundInput, + compoundOperatorList: [ + { operator: '', desc: 'Contains' }, + { operator: '<>', desc: 'Not Contains' }, + { operator: '=', desc: 'Equals' }, + { operator: '!=', desc: 'Not equal to' }, + { operator: 'a*', desc: 'Starts With' }, + { operator: 'Custom', desc: 'SQL Like' }, + ], } }, { @@ -144,6 +152,10 @@ export default class Example10 { enableAutoResize: false, gridHeight: 275, gridWidth: 900, + compoundOperatorAltTexts: { + // where '=' is any of the `OperatorString` type shown above + text: { 'Custom': { operatorAlt: '%%', descAlt: 'SQL Like' } }, + }, enableFiltering: true, enableCellNavigation: true, createPreHeaderPanel: true, @@ -193,6 +205,15 @@ export default class Example10 { field: 'userId', value: 123 }], + filterQueryOverride: ({ fieldName, columnDef, columnFilterOperator, searchValue }) => { + if (columnFilterOperator === OperatorType.custom && columnDef?.id === 'name') { + // technically speaking GraphQL isn't a database query language like SQL, it's an application query language. + // What that means is that GraphQL won't let you write arbitrary queries out of the box. + // It will only support the types of queries defined in your GraphQL schema. + // see this SO: https://stackoverflow.com/a/37981802/1212166 + return { field: fieldName, operator: 'Like', value: searchValue }; + } + }, useCursor: this.isWithCursor, // sets pagination strategy, if true requires a call to setPageInfo() when graphql call returns // when dealing with complex objects, we want to keep our field name with double quotes // example with gender: query { users (orderBy:[{field:"gender",direction:ASC}]) {} diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example23.ts b/examples/vite-demo-vanilla-bundle/src/examples/example23.ts index 1a8186582..2103e0c2a 100644 --- a/examples/vite-demo-vanilla-bundle/src/examples/example23.ts +++ b/examples/vite-demo-vanilla-bundle/src/examples/example23.ts @@ -97,7 +97,7 @@ export class CustomSumAggregator implements Aggregator { export default class Example19 { private _bindingEventService: BindingEventService; columnDefinitions: Column[] = []; - dataset: any[] = []; + dataset: GroceryItem[] = []; gridOptions!: GridOption; gridContainerElm: HTMLDivElement; sgb: SlickVanillaGridBundle; @@ -287,6 +287,7 @@ export default class Example19 { maxDecimal: 2, minDecimal: 2, }, + enableGrouping: true, externalResources: [this.excelExportService], enableExcelExport: true, excelExportOptions: { @@ -426,7 +427,7 @@ export default class Example19 { { id: i++, name: 'Chicken Wings', qty: 12, taxable: true, price: .53 }, { id: i++, name: 'Drinkable Yogurt', qty: 6, taxable: true, price: 1.22 }, { id: i++, name: 'Milk', qty: 3, taxable: true, price: 3.11 }, - ]; + ] as GroceryItem[]; return datasetTmp; } diff --git a/packages/graphql/src/interfaces/graphqlFilteringOption.interface.ts b/packages/graphql/src/interfaces/graphqlFilteringOption.interface.ts index de8aa929c..182cf4481 100644 --- a/packages/graphql/src/interfaces/graphqlFilteringOption.interface.ts +++ b/packages/graphql/src/interfaces/graphqlFilteringOption.interface.ts @@ -10,3 +10,14 @@ export interface GraphqlFilteringOption { /** Value to use when filtering */ value: any | any[]; } + +export interface GraphqlCustomFilteringOption { + /** Field name to use when filtering */ + field: string; + + /** Custom Operator to use when filtering. Please note that any new Custom Operator must be implemented in your GraphQL Schema. */ + operator: OperatorType | OperatorString; + + /** Value to use when filtering */ + value: any | any[]; +} diff --git a/packages/graphql/src/interfaces/graphqlServiceOption.interface.ts b/packages/graphql/src/interfaces/graphqlServiceOption.interface.ts index ab1ac9107..086232e99 100644 --- a/packages/graphql/src/interfaces/graphqlServiceOption.interface.ts +++ b/packages/graphql/src/interfaces/graphqlServiceOption.interface.ts @@ -1,6 +1,6 @@ -import type { BackendServiceOption } from '@slickgrid-universal/common'; +import type { BackendServiceOption, BackendServiceFilterQueryOverrideArgs } from '@slickgrid-universal/common'; -import type { GraphqlFilteringOption } from './graphqlFilteringOption.interface'; +import type { GraphqlCustomFilteringOption, GraphqlFilteringOption } from './graphqlFilteringOption.interface'; import type { GraphqlSortingOption } from './graphqlSortingOption.interface'; import type { GraphqlCursorPaginationOption } from './graphqlCursorPaginationOption.interface'; import type { GraphqlPaginationOption } from './graphqlPaginationOption.interface'; @@ -29,6 +29,9 @@ export interface GraphqlServiceOption extends BackendServiceOption { /** array of Filtering Options, ex.: { field: name, operator: EQ, value: "John" } */ filteringOptions?: GraphqlFilteringOption[]; + /** An optional predicate function to overide the built-in filter construction */ + filterQueryOverride?: (args: BackendServiceFilterQueryOverrideArgs) => GraphqlCustomFilteringOption | undefined; + /** What are the pagination options? ex.: (first, last, offset) */ paginationOptions?: GraphqlPaginationOption | GraphqlCursorPaginationOption; diff --git a/packages/graphql/src/services/__tests__/graphql.service.spec.ts b/packages/graphql/src/services/__tests__/graphql.service.spec.ts index 0fae0c141..d167da4bc 100644 --- a/packages/graphql/src/services/__tests__/graphql.service.spec.ts +++ b/packages/graphql/src/services/__tests__/graphql.service.spec.ts @@ -944,6 +944,50 @@ describe('GraphqlService', () => { expect(removeSpaces(query)).toBe(removeSpaces(expectation)); }); + it('should bypass default behavior if filterQueryOverride is defined and does not return undefined', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:foo, operator:EQ, value:"bar"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'name', field: 'name' } as Column; + const mockColumnFilters = { + name: { columnId: 'name', columnDef: mockColumn, searchTerms: ['Ca*le'], operator: 'a*z', type: FieldType.string }, + } as ColumnFilters; + + const sOptions = { ...serviceOptions, filterQueryOverride: () => ({ field: 'foo', operator: OperatorType.equal, value: 'bar' }) }; + service.init(sOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should continue with default behavior if filterQueryOverride returns undefined', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:name, operator:StartsWith, value:"Ca"},{field:name, operator:EndsWith, value:"le"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'name', field: 'name' } as Column; + const mockColumnFilters = { + name: { columnId: 'name', columnDef: mockColumn, searchTerms: ['Ca*le'], operator: 'a*z', type: FieldType.string }, + } as ColumnFilters; + + const sOptions = { ...serviceOptions, filterQueryOverride: () => undefined }; + service.init(sOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should continue with default behavior if filterQueryOverride is not provided', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:name, operator:StartsWith, value:"Ca"},{field:name, operator:EndsWith, value:"le"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'name', field: 'name' } as Column; + const mockColumnFilters = { + name: { columnId: 'name', columnDef: mockColumn, searchTerms: ['Ca*le'], operator: 'a*z', type: FieldType.string }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + it('should return a query with search having the operator Greater of Equal when the search value was provided as ">=10"', () => { const expectation = `query{users(first:10, offset:0, filterBy:[{field:age, operator:GE, value:"10"}]) { totalCount,nodes{ id,company,gender,name } }}`; const mockColumn = { id: 'age', field: 'age' } as Column; diff --git a/packages/graphql/src/services/graphql.service.ts b/packages/graphql/src/services/graphql.service.ts index f23ae7a63..e989e2a82 100644 --- a/packages/graphql/src/services/graphql.service.ts +++ b/packages/graphql/src/services/graphql.service.ts @@ -31,6 +31,7 @@ import { getHtmlStringOutput, stripTags } from '@slickgrid-universal/utils'; import type { GraphqlCursorPaginationOption, + GraphqlCustomFilteringOption, GraphqlDatasetFilter, GraphqlFilteringOption, GraphqlPaginationOption, @@ -378,7 +379,7 @@ export class GraphqlService implements BackendService { * @param columnFilters */ updateFilters(columnFilters: ColumnFilters | CurrentFilter[], isUpdatedByPresetOrDynamically: boolean) { - const searchByArray: GraphqlFilteringOption[] = []; + const searchByArray: Array = []; let searchValue: string | string[]; // on filter preset load, we need to keep current filters @@ -442,80 +443,98 @@ export class GraphqlService implements BackendService { continue; } - // StartsWith + EndsWith combo - if (comboStartsWith && comboEndsWith) { - searchTerms = [comboStartsWith, comboEndsWith]; - operator = OperatorType.startsWithEndsWith; - } else if (Array.isArray(searchTerms) && searchTerms.length === 1 && typeof searchTerms[0] === 'string' && searchTerms[0].indexOf('..') >= 0) { - if (operator !== OperatorType.rangeInclusive && operator !== OperatorType.rangeExclusive) { - operator = this._gridOptions.defaultFilterRangeOperator ?? OperatorType.rangeInclusive; + let filterQueryOverride: GraphqlCustomFilteringOption | undefined = undefined; + if (typeof this.options?.filterQueryOverride === 'function') { + filterQueryOverride = this.options?.filterQueryOverride({ + fieldName: getHtmlStringOutput(fieldName), + columnDef, + operator, + columnFilterOperator: columnFilter.operator, + searchValue, + grid: this._grid + }); + } + + if (filterQueryOverride !== undefined) { + // since this is a Custom Filter, we expect Operator to be a string + // and it is assumed that the developer will implement this custom operator in their GraphQL Schema + // e.g.: https://stackoverflow.com/a/37981802/1212166 + searchByArray.push(filterQueryOverride); + } else { + if (comboStartsWith && comboEndsWith) { + searchTerms = [comboStartsWith, comboEndsWith]; + operator = OperatorType.startsWithEndsWith; + } else if (Array.isArray(searchTerms) && searchTerms.length === 1 && typeof searchTerms[0] === 'string' && searchTerms[0].indexOf('..') >= 0) { + if (operator !== OperatorType.rangeInclusive && operator !== OperatorType.rangeExclusive) { + operator = this._gridOptions.defaultFilterRangeOperator ?? OperatorType.rangeInclusive; + } + searchTerms = searchTerms[0].split('..', 2); + if (searchTerms[0] === '') { + operator = operator === OperatorType.rangeInclusive ? '<=' : operator === OperatorType.rangeExclusive ? '<' : operator; + searchTerms = searchTerms.slice(1); + searchValue = searchTerms[0]; + } else if (searchTerms[1] === '') { + operator = operator === OperatorType.rangeInclusive ? '>=' : operator === OperatorType.rangeExclusive ? '>' : operator; + searchTerms = searchTerms.slice(0, 1); + searchValue = searchTerms[0]; + } } - searchTerms = searchTerms[0].split('..', 2); - if (searchTerms[0] === '') { - operator = operator === OperatorType.rangeInclusive ? '<=' : operator === OperatorType.rangeExclusive ? '<' : operator; - searchTerms = searchTerms.slice(1); - searchValue = searchTerms[0]; - } else if (searchTerms[1] === '') { - operator = operator === OperatorType.rangeInclusive ? '>=' : operator === OperatorType.rangeExclusive ? '>' : operator; - searchTerms = searchTerms.slice(0, 1); - searchValue = searchTerms[0]; + + if (typeof searchValue === 'string') { + if (operator === '*' || operator === 'a*' || operator === '*z' || lastValueChar === '*') { + operator = ((operator === '*' || operator === '*z') ? 'EndsWith' : 'StartsWith') as OperatorString; + } } - } - if (typeof searchValue === 'string') { - if (operator === '*' || operator === 'a*' || operator === '*z' || lastValueChar === '*') { - operator = ((operator === '*' || operator === '*z') ? 'EndsWith' : 'StartsWith') as OperatorString; + // if we didn't find an Operator but we have a Column Operator inside the Filter (DOM Element), we should use its default Operator + // multipleSelect is "IN", while singleSelect is "EQ", else don't map any operator + if (!operator && columnDef.filter && columnDef.filter.operator) { + operator = columnDef.filter.operator; } - } - // if we didn't find an Operator but we have a Column Operator inside the Filter (DOM Element), we should use its default Operator - // multipleSelect is "IN", while singleSelect is "EQ", else don't map any operator - if (!operator && columnDef.filter && columnDef.filter.operator) { - operator = columnDef.filter.operator; - } + // No operator and 2 search terms should lead to default range operator. + if (!operator && Array.isArray(searchTerms) && searchTerms.length === 2 && searchTerms[0] && searchTerms[1]) { + operator = this._gridOptions.defaultFilterRangeOperator as OperatorString; + } - // No operator and 2 search terms should lead to default range operator. - if (!operator && Array.isArray(searchTerms) && searchTerms.length === 2 && searchTerms[0] && searchTerms[1]) { - operator = this._gridOptions.defaultFilterRangeOperator as OperatorString; - } + // Range with 1 searchterm should lead to equals for a date field. + if ((operator === OperatorType.rangeInclusive || operator === OperatorType.rangeExclusive) && Array.isArray(searchTerms) && searchTerms.length === 1 && fieldType === FieldType.date) { + operator = OperatorType.equal; + } - // Range with 1 searchterm should lead to equals for a date field. - if ((operator === OperatorType.rangeInclusive || operator === OperatorType.rangeExclusive) && Array.isArray(searchTerms) && searchTerms.length === 1 && fieldType === FieldType.date) { - operator = OperatorType.equal; - } + // Normalize all search values + searchValue = this.normalizeSearchValue(fieldType, searchValue); + if (Array.isArray(searchTerms)) { + searchTerms.forEach((_part, index) => { + searchTerms[index] = this.normalizeSearchValue(fieldType, searchTerms[index]); + }); + } - // Normalize all search values - searchValue = this.normalizeSearchValue(fieldType, searchValue); - if (Array.isArray(searchTerms)) { - searchTerms.forEach((_part, index) => { - searchTerms[index] = this.normalizeSearchValue(fieldType, searchTerms[index]); - }); - } + // StartsWith + EndsWith combo + if (operator === OperatorType.startsWithEndsWith && Array.isArray(searchTerms) && searchTerms.length === 2) { + // add 2 conditions (StartsWith A + EndsWith B) to the search array + searchByArray.push({ field: getHtmlStringOutput(fieldName), operator: OperatorType.startsWith, value: comboStartsWith }); + searchByArray.push({ field: getHtmlStringOutput(fieldName), operator: OperatorType.endsWith, value: comboEndsWith }); + continue; + } - // StartsWith + EndsWith combo - if (operator === OperatorType.startsWithEndsWith && Array.isArray(searchTerms) && searchTerms.length === 2) { - // add 2 conditions (StartsWith A + EndsWith B) to the search array - searchByArray.push({ field: getHtmlStringOutput(fieldName), operator: OperatorType.startsWith, value: comboStartsWith }); - searchByArray.push({ field: getHtmlStringOutput(fieldName), operator: OperatorType.endsWith, value: comboEndsWith }); - continue; - } + // when having more than 1 search term (we need to create a CSV string for GraphQL "IN" or "NOT IN" filter search) + if (searchTerms?.length > 1 && (operator === 'IN' || operator === 'NIN' || operator === 'NOT_IN')) { + searchValue = searchTerms.join(','); + } else if (searchTerms?.length === 2 && (operator === OperatorType.rangeExclusive || operator === OperatorType.rangeInclusive)) { + searchByArray.push({ field: getHtmlStringOutput(fieldName), operator: (operator === OperatorType.rangeInclusive ? 'GE' : 'GT'), value: searchTerms[0] }); + searchByArray.push({ field: getHtmlStringOutput(fieldName), operator: (operator === OperatorType.rangeInclusive ? 'LE' : 'LT'), value: searchTerms[1] }); + continue; + } - // when having more than 1 search term (we need to create a CSV string for GraphQL "IN" or "NOT IN" filter search) - if (searchTerms?.length > 1 && (operator === 'IN' || operator === 'NIN' || operator === 'NOT_IN')) { - searchValue = searchTerms.join(','); - } else if (searchTerms?.length === 2 && (operator === OperatorType.rangeExclusive || operator === OperatorType.rangeInclusive)) { - searchByArray.push({ field: getHtmlStringOutput(fieldName), operator: (operator === OperatorType.rangeInclusive ? 'GE' : 'GT'), value: searchTerms[0] }); - searchByArray.push({ field: getHtmlStringOutput(fieldName), operator: (operator === OperatorType.rangeInclusive ? 'LE' : 'LT'), value: searchTerms[1] }); - continue; - } + // if we still don't have an operator find the proper Operator to use according field type + if (!operator) { + operator = mapOperatorByFieldType(fieldType); + } - // if we still don't have an operator find the proper Operator to use according field type - if (!operator) { - operator = mapOperatorByFieldType(fieldType); + // build the search array + searchByArray.push({ field: getHtmlStringOutput(fieldName), operator: mapOperatorType(operator), value: searchValue }); } - - // build the search array - searchByArray.push({ field: getHtmlStringOutput(fieldName), operator: mapOperatorType(operator), value: searchValue }); } } diff --git a/test/cypress/e2e/example10.cy.ts b/test/cypress/e2e/example10.cy.ts index 94a1ffd37..c502f5aaa 100644 --- a/test/cypress/e2e/example10.cy.ts +++ b/test/cypress/e2e/example10.cy.ts @@ -307,6 +307,29 @@ describe('Example 10 - GraphQL Grid', () => { }); }); + it('should perform filterQueryOverride when operator "%%" is selected', () => { + cy.get('.search-filter.filter-name select').find('option').last().then((element) => { + cy.get('.search-filter.filter-name select').select(element.val()); + }); + + cy.get('.search-filter.filter-name') + .find('input') + .clear() + .type('Jo%yn%er'); + + // wait for the query to finish + cy.get('[data-test=status]').should('contain', 'finished'); + + cy.get('[data-test=graphql-query-result]') + .should(($span) => { + const text = removeSpaces($span.text()); // remove all white spaces + expect(text).to.eq(removeSpaces(`query { users (first:30,offset:0, + orderBy:[{field:"name",direction:ASC}], + filterBy:[{field:"name",operator:Like,value:"Jo%yn%er"}], + locale:"en",userId:123) { totalCount, nodes { id,name,gender,company,billing{address{street,zip}},finish } } }`)); + }); + }); + it('should click on Set Dynamic Filter and expect query and filters to be changed', () => { cy.get('[data-test=set-dynamic-filter]') .click();