From 2823fd003cac9462c8ad945aa59bf56e9dc155af Mon Sep 17 00:00:00 2001 From: Ghislain Beaulac Date: Thu, 4 Jun 2020 17:49:19 -0400 Subject: [PATCH 01/66] feat(backend): add OData & GraphQL packages --- README.md | 8 +- packages/common/src/enums/caseType.enum.ts | 13 + packages/common/src/enums/index.ts | 1 + .../backendServiceOption.interface.ts | 2 - packages/common/src/interfaces/index.ts | 8 +- packages/graphql/README.md | 37 + packages/graphql/package.json | 43 + packages/graphql/src/index.spec.ts | 16 + packages/graphql/src/index.ts | 2 + ...graphqlCursorPaginationOption.interface.ts | 13 + .../graphqlDatasetFilter.interface.ts | 13 + .../graphqlFilteringOption.interface.ts | 12 + .../graphqlPaginatedResult.interface.ts | 39 + .../graphqlPaginationOption.interface.ts | 5 + .../src/interfaces/graphqlResult.interface.ts | 10 + .../interfaces/graphqlServiceApi.interface.ts | 29 + .../graphqlServiceOption.interface.ts | 43 + .../graphqlSortingOption.interface.ts | 6 + packages/graphql/src/interfaces/index.ts | 10 + .../src/interfaces/queryArgument.interface.ts | 4 + .../__tests__/graphql.service.spec.ts | 1263 +++++++++++++ .../__tests__/graphqlQueryBuilder.spec.ts | 203 +++ .../graphql/src/services/graphql.service.ts | 614 +++++++ .../src/services/graphqlQueryBuilder.ts | 141 ++ packages/graphql/src/services/index.ts | 2 + packages/graphql/tsconfig.build.json | 40 + packages/graphql/tsconfig.json | 41 + packages/odata/README.md | 37 + packages/odata/package.json | 40 + packages/odata/src/index.spec.ts | 16 + packages/odata/src/index.ts | 2 + packages/odata/src/interfaces/index.ts | 3 + .../src/interfaces/odataOption.interface.ts | 33 + .../interfaces/odataServiceApi.interface.ts | 11 + .../odataSortingOption.interface.ts | 6 + .../__tests__/grid-odata.service.spec.ts | 1605 +++++++++++++++++ .../odataQueryBuilder.service.spec.ts | 218 +++ .../odata/src/services/grid-odata.service.ts | 613 +++++++ packages/odata/src/services/index.ts | 2 + .../src/services/odataQueryBuilder.service.ts | 150 ++ packages/odata/tsconfig.build.json | 40 + packages/odata/tsconfig.json | 41 + 42 files changed, 5425 insertions(+), 10 deletions(-) create mode 100644 packages/common/src/enums/caseType.enum.ts create mode 100644 packages/graphql/README.md create mode 100644 packages/graphql/package.json create mode 100644 packages/graphql/src/index.spec.ts create mode 100644 packages/graphql/src/index.ts create mode 100644 packages/graphql/src/interfaces/graphqlCursorPaginationOption.interface.ts create mode 100644 packages/graphql/src/interfaces/graphqlDatasetFilter.interface.ts create mode 100644 packages/graphql/src/interfaces/graphqlFilteringOption.interface.ts create mode 100644 packages/graphql/src/interfaces/graphqlPaginatedResult.interface.ts create mode 100644 packages/graphql/src/interfaces/graphqlPaginationOption.interface.ts create mode 100644 packages/graphql/src/interfaces/graphqlResult.interface.ts create mode 100644 packages/graphql/src/interfaces/graphqlServiceApi.interface.ts create mode 100644 packages/graphql/src/interfaces/graphqlServiceOption.interface.ts create mode 100644 packages/graphql/src/interfaces/graphqlSortingOption.interface.ts create mode 100644 packages/graphql/src/interfaces/index.ts create mode 100644 packages/graphql/src/interfaces/queryArgument.interface.ts create mode 100644 packages/graphql/src/services/__tests__/graphql.service.spec.ts create mode 100644 packages/graphql/src/services/__tests__/graphqlQueryBuilder.spec.ts create mode 100644 packages/graphql/src/services/graphql.service.ts create mode 100644 packages/graphql/src/services/graphqlQueryBuilder.ts create mode 100644 packages/graphql/src/services/index.ts create mode 100644 packages/graphql/tsconfig.build.json create mode 100644 packages/graphql/tsconfig.json create mode 100644 packages/odata/README.md create mode 100644 packages/odata/package.json create mode 100644 packages/odata/src/index.spec.ts create mode 100644 packages/odata/src/index.ts create mode 100644 packages/odata/src/interfaces/index.ts create mode 100644 packages/odata/src/interfaces/odataOption.interface.ts create mode 100644 packages/odata/src/interfaces/odataServiceApi.interface.ts create mode 100644 packages/odata/src/interfaces/odataSortingOption.interface.ts create mode 100644 packages/odata/src/services/__tests__/grid-odata.service.spec.ts create mode 100644 packages/odata/src/services/__tests__/odataQueryBuilder.service.spec.ts create mode 100644 packages/odata/src/services/grid-odata.service.ts create mode 100644 packages/odata/src/services/index.ts create mode 100644 packages/odata/src/services/odataQueryBuilder.service.ts create mode 100644 packages/odata/tsconfig.build.json create mode 100644 packages/odata/tsconfig.json diff --git a/README.md b/README.md index 908eb7005..ca969cc28 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ and it is also used to test with the UI portion. The Vanilla bundle is also used - this can then be used by any Framework (Angular, Aurelia, VanillaJS, ...) - `@slickgrid-universal/excel-export`: export to Excel (xls/xlsx) - `@slickgrid-universal/file-export`: export to text file (csv/txt) -- `@slickgrid-universal/graphql`: GraphQL querying (support Filter/Sort/Pagination) - COMING SOON -- `@slickgrid-universal/odata`: OData querying (support Filter/Sort/Pagination) - COMING SOON +- `@slickgrid-universal/graphql`: GraphQL querying (support Filter/Sort/Pagination with a GraphQL backend Server) +- `@slickgrid-universal/odata`: OData querying (support Filter/Sort/Pagination with an OData backend Server) - `@slickgrid-universal/vanilla-bundle`: a vanilla TypeScript/JavaScript implementation (framework-less) -   - **Standalone Package** @@ -112,8 +112,8 @@ npm run test:watch - [x] Export Text (**separate package**) - [x] Extension - [x] Filter - - [ ] GraphQL (**separate package**) - - [ ] OData (**separate package**) + - [x] GraphQL (**separate package**) + - [x] OData (**separate package**) - [x] Grid Event - [x] Grid Service (helper) - [x] Grid State diff --git a/packages/common/src/enums/caseType.enum.ts b/packages/common/src/enums/caseType.enum.ts new file mode 100644 index 000000000..4eead4e1c --- /dev/null +++ b/packages/common/src/enums/caseType.enum.ts @@ -0,0 +1,13 @@ +export enum CaseType { + /** For example: camelCase */ + camelCase, + + /** For example: PascalCase */ + pascalCase, + + /** For example: snake_case */ + snakeCase, + + /** For example: kebab-case */ + kebabCase, +} diff --git a/packages/common/src/enums/index.ts b/packages/common/src/enums/index.ts index 0c3ff1629..eec3f0da0 100644 --- a/packages/common/src/enums/index.ts +++ b/packages/common/src/enums/index.ts @@ -1,3 +1,4 @@ +export * from './caseType.enum'; export * from './delimiterType.enum'; export * from './emitterType.enum'; export * from './eventNamingStyle.enum'; diff --git a/packages/common/src/interfaces/backendServiceOption.interface.ts b/packages/common/src/interfaces/backendServiceOption.interface.ts index 1454b633d..0292d3cd5 100644 --- a/packages/common/src/interfaces/backendServiceOption.interface.ts +++ b/packages/common/src/interfaces/backendServiceOption.interface.ts @@ -1,5 +1,3 @@ -import { Column } from './column.interface'; - export interface BackendServiceOption { /** What are the pagination options? ex.: (first, last, offset) */ paginationOptions?: any; diff --git a/packages/common/src/interfaces/index.ts b/packages/common/src/interfaces/index.ts index c5bcff360..7d8e09b52 100644 --- a/packages/common/src/interfaces/index.ts +++ b/packages/common/src/interfaces/index.ts @@ -60,6 +60,7 @@ export * from './gridMenuItem.interface'; export * from './gridOption.interface'; export * from './gridServiceDeleteOption.interface'; export * from './gridServiceInsertOption.interface'; +export * from './gridServiceUpdateOption.interface'; export * from './gridState.interface'; export * from './gridStateChange.interface'; export * from './grouping.interface'; @@ -71,7 +72,7 @@ export * from './headerButtonItem.interface'; export * from './headerButtonOnCommandArgs.interface'; export * from './headerMenu.interface'; export * from './htmlElementPosition.interface'; -export * from './gridServiceUpdateOption.interface'; +export * from './jQueryUiSliderOption.interface'; export * from './jQueryUiSliderResponse.interface'; export * from './keyTitlePair.interface'; export * from './locale.interface'; @@ -82,14 +83,13 @@ export * from './menuItem.interface'; export * from './menuOptionItem.interface'; export * from './menuOptionItemCallbackArgs.interface'; export * from './metrics.interface'; -export * from './jQueryUiSliderOption.interface'; export * from './multiColumnSort.interface'; -export * from './onEventArgs.interface'; export * from './multipleSelectOption.interface'; +export * from './onEventArgs.interface'; export * from './onValidationErrorResult.interface'; export * from './pagination.interface'; -export * from './pagingInfo.interface'; export * from './paginationChangedArgs.interface'; +export * from './pagingInfo.interface'; export * from './rowMoveManager.interface'; export * from './selectedRange.interface'; export * from './selectOption.interface'; diff --git a/packages/graphql/README.md b/packages/graphql/README.md new file mode 100644 index 000000000..0f2eb0d58 --- /dev/null +++ b/packages/graphql/README.md @@ -0,0 +1,37 @@ +## GraphQL Service +#### @slickgrid-universal/graphql + +GraphQL Service to sync a grid with an GraphQL backend server, the service will consider any Filter/Sort and automatically build the necessary GraphQL query string that is sent to your GraphQL backend server. + +### Dependencies +No external dependency + +### Installation +Follow the instruction provided in the main [README](https://github.com/ghiscoding/slickgrid-universal#installation), you can see a demo by looking at the [GitHub Demo](https://ghiscoding.github.io/slickgrid-universal) page. + +### Usage +Simply use pass the Service into the `backendServiceApi` Grid Option. + +##### ViewModel +```ts +import { GraphqlService, GraphqlServiceApi } from '@slickgrid-universal/graphql'; + +export class MyExample { + prepareGrid { + this.gridOptions = { + backendServiceApi: { + service: new GraphqlService(), + options: { + datasetName: 'users', + }, + preProcess: () => this.displaySpinner(true), + process: (query) => this.getCustomerApiCall(query), + postProcess: (response) => { + this.displaySpinner(false); + this.getCustomerCallback(response); + } + } as GraphqlServiceApi + } + } +} +``` diff --git a/packages/graphql/package.json b/packages/graphql/package.json new file mode 100644 index 000000000..229606e3f --- /dev/null +++ b/packages/graphql/package.json @@ -0,0 +1,43 @@ +{ + "name": "@slickgrid-universal/graphql", + "version": "0.0.2", + "description": "GraphQL Service to sync a grid with a GraphQL backend server", + "browser": "src/index.ts", + "main": "dist/commonjs/index.js", + "module": "dist/es2015/index.js", + "typings": "dist/commonjs/index.d.ts", + "files": [ + "src", + "dist" + ], + "scripts": { + "build": "cross-env tsc --build", + "build:watch": "cross-env tsc --incremental --watch", + "dev": "run-s build sass:build sass:copy", + "dev:watch": "run-p build:watch", + "bundle:commonjs": "tsc --project tsconfig.build.json --outDir dist/commonjs --module commonjs", + "bundle:es2015": "cross-env tsc --project tsconfig.build.json --outDir dist/es2015 --module es2020 --target es2015", + "bundle:es2020": "cross-env tsc --project tsconfig.build.json --outDir dist/es2020 --module es2015 --target es2020", + "bundle": "npm-run-all bundle:commonjs bundle:es2015 bundle:es2020", + "prebundle": "npm-run-all delete:dist", + "delete:dist": "cross-env rimraf dist" + }, + "author": "Ghislain B.", + "license": "MIT", + "engines": { + "node": ">=12.13.1", + "npm": ">=6.12.1" + }, + "browserslist": [ + "last 2 version", + "> 1%", + "maintained node versions", + "not dead" + ], + "dependencies": { + "@slickgrid-universal/common": "^0.0.2" + }, + "devDependencies": { + "moment-mini": "^2.24.0" + } +} diff --git a/packages/graphql/src/index.spec.ts b/packages/graphql/src/index.spec.ts new file mode 100644 index 000000000..62066e7f0 --- /dev/null +++ b/packages/graphql/src/index.spec.ts @@ -0,0 +1,16 @@ +import * as entry from './index'; +import * as interfaces from './interfaces/index'; +import * as services from './services/index'; + +describe('Testing GraphQL Package entry point', () => { + it('should have multiple index entries defined', () => { + expect(entry).toBeTruthy(); + expect(interfaces).toBeTruthy(); + expect(services).toBeTruthy(); + }); + + it('should have 2x Services defined', () => { + expect(typeof entry.GraphqlService).toBeTruthy(); + expect(typeof entry.GraphqlQueryBuilder).toBeTruthy(); + }); +}); diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts new file mode 100644 index 000000000..5181074f8 --- /dev/null +++ b/packages/graphql/src/index.ts @@ -0,0 +1,2 @@ +export { GraphqlService } from './services/graphql.service'; +export { default as GraphqlQueryBuilder } from './services/graphqlQueryBuilder'; diff --git a/packages/graphql/src/interfaces/graphqlCursorPaginationOption.interface.ts b/packages/graphql/src/interfaces/graphqlCursorPaginationOption.interface.ts new file mode 100644 index 000000000..02ac38a51 --- /dev/null +++ b/packages/graphql/src/interfaces/graphqlCursorPaginationOption.interface.ts @@ -0,0 +1,13 @@ +export interface GraphqlCursorPaginationOption { + /** Start our page After cursor X */ + after?: string; + + /** Start our page Before cursor X */ + before?: string; + + /** Get first X number of objects */ + first?: number; + + /** Get last X number of objects */ + last?: number; +} diff --git a/packages/graphql/src/interfaces/graphqlDatasetFilter.interface.ts b/packages/graphql/src/interfaces/graphqlDatasetFilter.interface.ts new file mode 100644 index 000000000..08bd97c85 --- /dev/null +++ b/packages/graphql/src/interfaces/graphqlDatasetFilter.interface.ts @@ -0,0 +1,13 @@ +import { GraphqlFilteringOption } from './graphqlFilteringOption.interface'; +import { GraphqlSortingOption } from './graphqlSortingOption.interface'; + +export interface GraphqlDatasetFilter { + first?: number; + last?: number; + offset?: number; + after?: string; + before?: string; + locale?: string; + filterBy?: GraphqlFilteringOption[]; + orderBy?: GraphqlSortingOption[]; +} diff --git a/packages/graphql/src/interfaces/graphqlFilteringOption.interface.ts b/packages/graphql/src/interfaces/graphqlFilteringOption.interface.ts new file mode 100644 index 000000000..d93d2ecbd --- /dev/null +++ b/packages/graphql/src/interfaces/graphqlFilteringOption.interface.ts @@ -0,0 +1,12 @@ +import { OperatorString, OperatorType } from '@slickgrid-universal/common'; + +export interface GraphqlFilteringOption { + /** Field name to use when filtering */ + field: string; + + /** Operator to use when filtering */ + operator: OperatorType | OperatorString; + + /** Value to use when filtering */ + value: any | any[]; +} diff --git a/packages/graphql/src/interfaces/graphqlPaginatedResult.interface.ts b/packages/graphql/src/interfaces/graphqlPaginatedResult.interface.ts new file mode 100644 index 000000000..6eb1e1b2e --- /dev/null +++ b/packages/graphql/src/interfaces/graphqlPaginatedResult.interface.ts @@ -0,0 +1,39 @@ +import { Metrics } from '@slickgrid-universal/common'; + +export interface GraphqlPaginatedResult { + data: { + [datasetName: string]: { + /** result set of data objects (array of data) */ + nodes: any[]; + + /** Total count of items in the table (needed for the Pagination to work) */ + totalCount: number; + + // --- + // When using a Cursor, we'll also have Edges and PageInfo according to a cursor position + /** Edges information of the current cursor */ + edges?: { + /** Current cursor position */ + cursor: string; + } + + /** Page information of the current cursor, do we have a next page and what is the end cursor? */ + pageInfo?: { + /** Do we have a next page from current cursor position? */ + hasNextPage: boolean; + + /** Do we have a previous page from current cursor position? */ + hasPreviousPage: boolean; + + /** What is the last cursor? */ + endCursor: string; + + /** What is the first cursor? */ + startCursor: string; + }; + } + }; + + /** Some metrics of the last executed query (startTime, endTime, executionTime, itemCount, totalItemCount) */ + metrics?: Metrics; +} diff --git a/packages/graphql/src/interfaces/graphqlPaginationOption.interface.ts b/packages/graphql/src/interfaces/graphqlPaginationOption.interface.ts new file mode 100644 index 000000000..92a6d3dc1 --- /dev/null +++ b/packages/graphql/src/interfaces/graphqlPaginationOption.interface.ts @@ -0,0 +1,5 @@ +export interface GraphqlPaginationOption { + first?: number; + last?: number; + offset?: number; +} diff --git a/packages/graphql/src/interfaces/graphqlResult.interface.ts b/packages/graphql/src/interfaces/graphqlResult.interface.ts new file mode 100644 index 000000000..7ff486f52 --- /dev/null +++ b/packages/graphql/src/interfaces/graphqlResult.interface.ts @@ -0,0 +1,10 @@ +import { Metrics } from '@slickgrid-universal/common'; + +export interface GraphqlResult { + data: { + [datasetName: string]: any[]; + }; + + /** Some metrics of the last executed query (startTime, endTime, executionTime, itemCount, totalItemCount) */ + metrics?: Metrics; +} diff --git a/packages/graphql/src/interfaces/graphqlServiceApi.interface.ts b/packages/graphql/src/interfaces/graphqlServiceApi.interface.ts new file mode 100644 index 000000000..7f4e77ccc --- /dev/null +++ b/packages/graphql/src/interfaces/graphqlServiceApi.interface.ts @@ -0,0 +1,29 @@ +import { BackendServiceApi } from '@slickgrid-universal/common'; + +import { GraphqlResult } from './graphqlResult.interface'; +import { GraphqlPaginatedResult } from './graphqlPaginatedResult.interface'; +import { GraphqlServiceOption } from './graphqlServiceOption.interface'; +import { GraphqlService } from '../services'; + +export interface GraphqlServiceApi extends BackendServiceApi { + /** Backend Service Options */ + options: GraphqlServiceOption; + + /** Backend Service instance (could be OData or GraphQL Service) */ + service: GraphqlService; + + /** On init (or on page load), what action to perform? */ + onInit?: (query: string) => Promise; + + /** On Processing, we get the query back from the service, and we need to provide a Promise. For example: this.http.get(myGraphqlUrl) */ + process: (query: string) => Promise; + + /** After executing the query, what action to perform? For example, stop the spinner */ + postProcess?: (response: GraphqlResult | GraphqlPaginatedResult) => void; + + /** + * INTERNAL USAGE ONLY by Aurelia-Slickgrid + * This internal process will be run just before postProcess and is meant to refresh the Dataset & Pagination after a GraphQL call + */ + internalPostProcess?: (result: GraphqlResult | GraphqlPaginatedResult) => void; +} diff --git a/packages/graphql/src/interfaces/graphqlServiceOption.interface.ts b/packages/graphql/src/interfaces/graphqlServiceOption.interface.ts new file mode 100644 index 000000000..f865f7d34 --- /dev/null +++ b/packages/graphql/src/interfaces/graphqlServiceOption.interface.ts @@ -0,0 +1,43 @@ +import { BackendServiceOption } from '@slickgrid-universal/common'; + +import { GraphqlFilteringOption } from './graphqlFilteringOption.interface'; +import { GraphqlSortingOption } from './graphqlSortingOption.interface'; +import { GraphqlCursorPaginationOption } from './graphqlCursorPaginationOption.interface'; +import { GraphqlPaginationOption } from './graphqlPaginationOption.interface'; +import { QueryArgument } from './queryArgument.interface'; + +export interface GraphqlServiceOption extends BackendServiceOption { + /** + * When using Translation, we probably want to add locale as a query parameter for the filterBy/orderBy to work + * ex.: users(first: 10, offset: 0, locale: "en-CA", filterBy: [{field: name, operator: EQ, value:"John"}]) { } + */ + addLocaleIntoQuery?: boolean; + + /** What is the dataset, this is required for the GraphQL query to be built */ + datasetName: string; + + /** + * Extra query arguments that be passed in addition to the default query arguments + * For example in GraphQL, if we want to pass "userId" and we want the query to look like + * users (first: 20, offset: 10, userId: 123) { ... } + */ + extraQueryArguments?: QueryArgument[]; + + /** (NOT FULLY IMPLEMENTED) Is the GraphQL Server using cursors? */ + isWithCursor?: boolean; + + /** What are the pagination options? ex.: (first, last, offset) */ + paginationOptions?: GraphqlPaginationOption | GraphqlCursorPaginationOption; + + /** array of Filtering Options, ex.: { field: name, operator: EQ, value: "John" } */ + filteringOptions?: GraphqlFilteringOption[]; + + /** array of Filtering Options, ex.: { field: name, direction: DESC } */ + sortingOptions?: GraphqlSortingOption[]; + + /** + * Do we want to keep double quotes on field arguments of filterBy/sortBy (field: "name" instead of field: name) + * ex.: { field: "name", operator: EQ, value: "John" } + */ + keepArgumentFieldDoubleQuotes?: boolean; +} diff --git a/packages/graphql/src/interfaces/graphqlSortingOption.interface.ts b/packages/graphql/src/interfaces/graphqlSortingOption.interface.ts new file mode 100644 index 000000000..f149d08d6 --- /dev/null +++ b/packages/graphql/src/interfaces/graphqlSortingOption.interface.ts @@ -0,0 +1,6 @@ +import { SortDirection, SortDirectionString } from '@slickgrid-universal/common'; + +export interface GraphqlSortingOption { + field: string; + direction: SortDirection | SortDirectionString; +} diff --git a/packages/graphql/src/interfaces/index.ts b/packages/graphql/src/interfaces/index.ts new file mode 100644 index 000000000..c839af8a3 --- /dev/null +++ b/packages/graphql/src/interfaces/index.ts @@ -0,0 +1,10 @@ +export * from './graphqlCursorPaginationOption.interface'; +export * from './graphqlDatasetFilter.interface'; +export * from './graphqlFilteringOption.interface'; +export * from './graphqlPaginatedResult.interface'; +export * from './graphqlPaginationOption.interface'; +export * from './graphqlResult.interface'; +export * from './graphqlServiceApi.interface'; +export * from './graphqlServiceOption.interface'; +export * from './graphqlSortingOption.interface'; +export * from './queryArgument.interface'; diff --git a/packages/graphql/src/interfaces/queryArgument.interface.ts b/packages/graphql/src/interfaces/queryArgument.interface.ts new file mode 100644 index 000000000..8410a6b9d --- /dev/null +++ b/packages/graphql/src/interfaces/queryArgument.interface.ts @@ -0,0 +1,4 @@ +export interface QueryArgument { + field: string; + value: string | number | boolean; +} diff --git a/packages/graphql/src/services/__tests__/graphql.service.spec.ts b/packages/graphql/src/services/__tests__/graphql.service.spec.ts new file mode 100644 index 000000000..319beca5a --- /dev/null +++ b/packages/graphql/src/services/__tests__/graphql.service.spec.ts @@ -0,0 +1,1263 @@ +import { + Column, + ColumnFilter, + ColumnFilters, + ColumnSort, + CurrentFilter, + CurrentSorter, + FieldType, + FilterChangedArgs, + GridOption, + MultiColumnSort, + OperatorType, + Pagination, + SlickGrid, + TranslaterService, +} from '@slickgrid-universal/common'; + +import { GraphqlServiceApi, GraphqlServiceOption, } from '../../interfaces/index'; +import { GraphqlService } from './../graphql.service'; +import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; + +const DEFAULT_ITEMS_PER_PAGE = 25; +const DEFAULT_PAGE_SIZE = 20; + +function removeSpaces(textS: string) { + return `${textS}`.replace(/\s+/g, ''); +} + +const gridOptionMock = { + enablePagination: true, + backendServiceApi: { + service: undefined, + options: { datasetName: '' }, + preProcess: jest.fn(), + process: jest.fn(), + postProcess: jest.fn(), + } as GraphqlServiceApi +} as GridOption; + +const gridStub = { + autosizeColumns: jest.fn(), + getColumnIndex: jest.fn(), + getScrollbarDimensions: jest.fn(), + getOptions: () => gridOptionMock, + getColumns: jest.fn(), + setColumns: jest.fn(), + registerPlugin: jest.fn(), + setSelectedRows: jest.fn(), + setSortColumns: jest.fn(), +} as unknown as SlickGrid; + +describe('GraphqlService', () => { + let mockColumns: Column[]; + let service: GraphqlService; + let paginationOptions: Pagination; + let serviceOptions: GraphqlServiceOption; + let translateService: TranslateServiceStub; + + beforeEach(() => { + mockColumns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + service = new GraphqlService(); + translateService = new TranslateServiceStub(); + serviceOptions = { + datasetName: 'users' + }; + paginationOptions = { + pageNumber: 1, + pageSizes: [5, 10, 25, 50, 100], + pageSize: 10, + totalItems: 100 + }; + gridOptionMock.backendServiceApi.service = service; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create the service', () => { + expect(service).toBeTruthy(); + }); + + describe('init method', () => { + it('should initialize the service and expect the service options and pagination to be set', () => { + service.init(serviceOptions, paginationOptions, gridStub); + expect(service.options).toEqual(serviceOptions); + expect(service.pagination).toEqual(paginationOptions); + }); + + it('should get the column definitions from "getColumns"', () => { + const columns = [{ id: 'field4', field: 'field4', width: 50 }, { id: 'field2', field: 'field2', width: 50 }]; + const spy = jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ datasetName: 'users' }, paginationOptions, gridStub); + + expect(spy).toHaveBeenCalled(); + expect(service.columnDefinitions).toEqual(columns); + }); + }); + + describe('buildQuery method', () => { + beforeEach(() => { + jest.resetAllMocks(); + jest.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); + }); + + it('should throw an error when no service options exists after service init', () => { + service.init(undefined); + expect(() => service.buildQuery()).toThrow(); + }); + + it('should throw an error when no dataset is provided in the service options after service init', () => { + service.init({ datasetName: undefined }); + expect(() => service.buildQuery()).toThrow('GraphQL Service requires the "datasetName" property to properly build the GraphQL query'); + }); + + it('should throw an error when no column definitions is provided in the service options after service init', () => { + service.init({ datasetName: 'users' }); + expect(() => service.buildQuery()).toThrow(); + }); + + it('should return a simple query with pagination set and nodes that includes "id" and the other 2 fields properties', () => { + const expectation = `query{ users(first:10, offset:0){ totalCount, nodes{ id, field1, field2 }}}`; + + service.init(serviceOptions, paginationOptions, gridStub); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should return a simple query without pagination (when disabled) set and nodes that includes "id" and the other 2 fields properties', () => { + gridOptionMock.enablePagination = false; + const expectation = `query{ users{ id, field1, field2 }}`; + + service.init(serviceOptions, paginationOptions, gridStub); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should use "columnDefinitions" from the "serviceOptions" when private member is undefined and then return a simple query as usual', () => { + gridOptionMock.enablePagination = true; + const expectation = `query{ users(first:10, offset:0){ totalCount, nodes{ id, field1 }}}`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ datasetName: 'users' }, paginationOptions, gridStub); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should return a simple query with pagination set and nodes that includes at least "id" when the column definitions is an empty array', () => { + const expectation = `query{ users(first:10, offset:0){ totalCount, nodes{ id }}}`; + const columns = []; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ datasetName: 'users' }, paginationOptions, gridStub); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should add extra column extra "fields" and expect them to be part of the query string', () => { + const expectation = `query{ users(first:10, offset:0){ totalCount, nodes{ id, field1, field2, field3, field4 }}}`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100, fields: ['field3', 'field4'] }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ datasetName: 'users' }, paginationOptions, gridStub); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should exclude a column and expect a query string without it', () => { + const expectation = `query{ users(first:10, offset:0){ totalCount, nodes{ id, field1 }}}`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100, excludeFromQuery: true }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ datasetName: 'users' }, paginationOptions, gridStub); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should use default pagination "first" option when "paginationOptions" is not provided', () => { + const expectation = `query{ users(first:${DEFAULT_ITEMS_PER_PAGE}, offset:0){ totalCount, nodes{ id, field1 }}}`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100, excludeFromQuery: true }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ datasetName: 'users' }, undefined, gridStub); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should return a simple query with pagination set and nodes that includes at least "id" when the column definitions is an empty array when using cursor', () => { + const expectation = `query{users(first:20) { totalCount, nodes{id}, pageInfo{ hasNextPage,hasPreviousPage,endCursor,startCursor }, edges{ cursor }}}`; + const columns = []; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ datasetName: 'users', isWithCursor: true }, paginationOptions, gridStub); + service.updatePagination(3, 20); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should return a query with pageInfo and edges included when cursor is enabled', () => { + const expectation = `query{users(first:20) { totalCount, nodes{id,field1}, pageInfo{ hasNextPage,hasPreviousPage,endCursor,startCursor }, edges{ cursor }}}`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ datasetName: 'users', isWithCursor: true }, paginationOptions, gridStub); + service.updatePagination(3, 20); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should return complex objects with dot notation and expect the query to be split and wrapped with curly braces', () => { + const expectation = `query{ users(first:10, offset:0){ totalCount, nodes{ id, field1, billing{address{street,zip}} }}}`; + const columns = [ + { id: 'field1', field: 'field1' }, + { id: 'billing.address.street', field: 'billing.address.street' }, + { id: 'billing.address.zip', field: 'billing.address.zip' } + ]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ datasetName: 'users' }, paginationOptions, gridStub); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should exclude pagination from the query string when the option is disabled', () => { + const expectation = `query{ users{ id, field1, field2 }}`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + gridOptionMock.enablePagination = false; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ datasetName: 'users' }, paginationOptions, gridStub); + service.updatePagination(3, 20); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + gridOptionMock.enablePagination = true; // reset it for the next test + }); + + it('should have a different pagination offset when it is updated before calling the buildQuery query (presets does that)', () => { + const expectation = `query{ users(first:20, offset:40){ totalCount, nodes{ id, field1, field2 }}}`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ datasetName: 'users' }, paginationOptions, gridStub); + service.updatePagination(3, 20); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should make sure the offset pagination is never below zero, even when new page is 0', () => { + const expectation = `query{ users(first:20, offset:0){ totalCount, nodes{ id, field1, field2 }}}`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ datasetName: 'users' }, paginationOptions, gridStub); + service.updatePagination(0, 20); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should make sure the offset pagination is never below zero, even when new is 1 the offset should remain 0', () => { + const expectation = `query{ users(first:20, offset:0){ totalCount, nodes{ id, field1, field2 }}}`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ datasetName: 'users' }, paginationOptions, gridStub); + service.updatePagination(1, 20); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should be able to provide "sortingOptions" and see the query string include the sorting', () => { + const expectation = `query{ users(first:20, offset:40,orderBy:[{field:field1, direction:DESC}]){ totalCount, nodes{ id, field1, field2 }}}`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ datasetName: 'users', sortingOptions: [{ field: 'field1', direction: 'DESC' }] }, paginationOptions, gridStub); + service.updatePagination(3, 20); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should be able to provide "filteringOptions" and see the query string include the filters', () => { + const expectation = `query{ users(first:20, offset:40,filterBy:[{field:field1, operator: >, value:"2000-10-10"}]){ totalCount, nodes{ id, field1, field2 }}}`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ datasetName: 'users', filteringOptions: [{ field: 'field1', operator: '>', value: '2000-10-10' }] }, paginationOptions, gridStub); + service.updatePagination(3, 20); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should be able to provide "sortingOptions" and see the query string include the sorting but without pagination when that is excluded', () => { + const expectation = `query{ users(orderBy:[{field:field1, direction:DESC}]){ id, field1, field2 }}`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + gridOptionMock.enablePagination = false; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ datasetName: 'users', sortingOptions: [{ field: 'field1', direction: 'DESC' }] }, paginationOptions, gridStub); + service.updatePagination(3, 20); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + gridOptionMock.enablePagination = true; // reset it for the next test + }); + + it('should be able to provide "filteringOptions" and see the query string include the filters but without pagination when that is excluded', () => { + const expectation = `query{ users(filterBy:[{field:field1, operator: >, value:"2000-10-10"}]){ id, field1, field2 }}`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + gridOptionMock.enablePagination = false; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ datasetName: 'users', filteringOptions: [{ field: 'field1', operator: '>', value: '2000-10-10' }] }, paginationOptions, gridStub); + service.updatePagination(3, 20); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + gridOptionMock.enablePagination = true; // reset it for the next test + }); + + it('should include default locale "en" in the query string when option "addLocaleIntoQuery" is enabled and i18n is not defined', () => { + const expectation = `query{ users(first:10, offset:0, locale: "en"){ totalCount, nodes{ id, field1, field2 }}}`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ datasetName: 'users', addLocaleIntoQuery: true }, paginationOptions, gridStub); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should include the locale in the query string when option "addLocaleIntoQuery" is enabled', () => { + const expectation = `query{ users(first:10, offset:0, locale: "fr-CA"){ totalCount, nodes{ id, field1, field2 }}}`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + gridOptionMock.i18n = { getCurrentLocale: () => 'fr-CA' } as TranslaterService; + service.init({ datasetName: 'users', addLocaleIntoQuery: true }, paginationOptions, gridStub); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should include extra query arguments in the query string when option "extraQueryArguments" is used', () => { + const expectation = `query{users(first:10, offset:0, userId:123, firstName:"John"){ totalCount, nodes{id,field1,field2}}}`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ + datasetName: 'users', + extraQueryArguments: [{ field: 'userId', value: 123 }, { field: 'firstName', value: 'John' }], + }, paginationOptions, gridStub); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should keep the double quotes in the field name when "keepArgumentFieldDoubleQuotes" is enabled', () => { + const expectation = `query { users + ( first:10, offset:0, + orderBy:[{ field:"field1", direction:DESC},{ field:"field2", direction:ASC }], + filterBy:[{ field:"field1", operator:>, value:"2000-10-10" },{ field:"field2", operator:EQ, value:"John" }] + ) { + totalCount, nodes { id,field1,field2 }} + }`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ + datasetName: 'users', + filteringOptions: [{ field: 'field1', operator: '>', value: '2000-10-10' }, { field: 'field2', operator: 'EQ', value: 'John' }], + sortingOptions: [{ field: 'field1', direction: 'DESC' }, { field: 'field2', direction: 'ASC' }], + keepArgumentFieldDoubleQuotes: true + }, paginationOptions, gridStub); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + }); + + describe('buildFilterQuery method', () => { + it('should return a simple query from an column array', () => { + const expectation = `firstName, lastName`; + const columns = ['firstName', 'lastName']; + + const query = service.buildFilterQuery(columns); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should return a query string including complex object', () => { + const expectation = `firstName, lastName, billing{address{street, zip}}`; + const columns = ['firstName', 'lastName', 'billing.address.street', 'billing.address.zip']; + + const query = service.buildFilterQuery(columns); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + }); + + describe('clearFilters method', () => { + it('should call "updateOptions" to clear all filters', () => { + const spy = jest.spyOn(service, 'updateOptions'); + service.clearFilters(); + expect(spy).toHaveBeenCalledWith({ filteringOptions: [] }); + }); + }); + + describe('clearSorters method', () => { + it('should call "updateOptions" to clear all sorting', () => { + const spy = jest.spyOn(service, 'updateOptions'); + service.clearSorters(); + expect(spy).toHaveBeenCalledWith({ sortingOptions: [] }); + }); + }); + + describe('getInitPaginationOptions method', () => { + beforeEach(() => { + paginationOptions.pageSize = 20; + }); + + it('should return the pagination options without cursor by default', () => { + jest.spyOn(gridStub, 'getColumns').mockReturnValue([]); + service.init({ datasetName: 'users' }, paginationOptions); + const output = service.getInitPaginationOptions(); + expect(output).toEqual({ first: 20, offset: 0 }); + }); + + it('should return the pagination options with cursor info when "isWithCursor" is enabled', () => { + jest.spyOn(gridStub, 'getColumns').mockReturnValue([]); + service.init({ datasetName: 'users', isWithCursor: true }, paginationOptions); + const output = service.getInitPaginationOptions(); + expect(output).toEqual({ first: 20 }); + }); + + it('should return the pagination options with default page size of 25 when "paginationOptions" is undefined', () => { + jest.spyOn(gridStub, 'getColumns').mockReturnValue([]); + service.init({ datasetName: 'users' }, undefined); + const output = service.getInitPaginationOptions(); + expect(output).toEqual({ first: DEFAULT_ITEMS_PER_PAGE, offset: 0 }); + }); + }); + + describe('getDatasetName method', () => { + it('should return the dataset name when defined', () => { + jest.spyOn(gridStub, 'getColumns').mockReturnValue([]); + service.init({ datasetName: 'users' }); + const output = service.getDatasetName(); + expect(output).toBe('users'); + }); + + it('should return empty string when dataset name is undefined', () => { + jest.spyOn(gridStub, 'getColumns').mockReturnValue([]); + service.init({ datasetName: undefined }); + const output = service.getDatasetName(); + expect(output).toBe(''); + }); + }); + + describe('resetPaginationOptions method', () => { + beforeEach(() => { + paginationOptions.pageSize = 20; + }); + + it('should reset the pagination options with default pagination', () => { + const spy = jest.spyOn(service, 'updateOptions'); + + jest.spyOn(gridStub, 'getColumns').mockReturnValue([]); + service.init({ datasetName: 'users' }, paginationOptions); + service.resetPaginationOptions(); + + expect(spy).toHaveBeenCalledWith({ paginationOptions: { first: 20, offset: 0 } }); + }); + + it('should reset the pagination options when using cursor', () => { + const spy = jest.spyOn(service, 'updateOptions'); + + jest.spyOn(gridStub, 'getColumns').mockReturnValue([]); + service.init({ datasetName: 'users', isWithCursor: true }, paginationOptions); + service.resetPaginationOptions(); + + expect(spy).toHaveBeenCalledWith({ paginationOptions: { after: '', before: undefined, last: undefined } }); + }); + }); + + describe('processOnFilterChanged method', () => { + it('should throw an error when backendService is undefined', () => { + service.init(serviceOptions, paginationOptions, undefined); + // @ts-ignore + expect(() => service.processOnFilterChanged(null, { grid: gridStub })).toThrow(); + }); + + it('should throw an error when grid is undefined', () => { + service.init(serviceOptions, paginationOptions, gridStub); + + // @ts-ignore + expect(() => service.processOnFilterChanged(null, { grid: undefined })) + .toThrowError('Something went wrong when trying create the GraphQL Backend Service'); + }); + + it('should return a query with the new filter', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:EQ, value:"female"}]) { totalCount,nodes{ id,field1,field2 } }}`; + const querySpy = jest.spyOn(service, 'buildQuery'); + const resetSpy = jest.spyOn(service, 'resetPaginationOptions'); + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilter = { columnDef: mockColumn, columnId: 'gender', operator: 'EQ', searchTerms: ['female'] } as ColumnFilter; + const mockFilterChangedArgs = { + columnDef: mockColumn, + columnId: 'gender', + columnFilters: { gender: mockColumnFilter }, + grid: gridStub, + operator: 'EQ', + searchTerms: ['female'], + shouldTriggerQuery: true + } as FilterChangedArgs; + + service.init(serviceOptions, paginationOptions, gridStub); + const query = service.processOnFilterChanged(null, mockFilterChangedArgs); + const currentFilters = service.getCurrentFilters(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(querySpy).toHaveBeenCalled(); + expect(resetSpy).toHaveBeenCalled(); + expect(currentFilters).toEqual([{ columnId: 'gender', operator: 'EQ', searchTerms: ['female'] }]); + }); + + it('should return a query with a new filter when previous filters exists', () => { + const expectation = `query{users(first:10, offset:0, + filterBy:[{field:gender, operator:EQ, value:"female"}, {field:firstName, operator:StartsWith, value:"John"}]) + { totalCount,nodes{ id,field1,field2 } }}`; + const querySpy = jest.spyOn(service, 'buildQuery'); + const resetSpy = jest.spyOn(service, 'resetPaginationOptions'); + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnName = { id: 'firstName', field: 'firstName' } as Column; + const mockColumnFilter = { columnDef: mockColumn, columnId: 'gender', operator: 'EQ', searchTerms: ['female'] } as ColumnFilter; + const mockColumnFilterName = { columnDef: mockColumnName, columnId: 'firstName', operator: 'StartsWith', searchTerms: ['John'] } as ColumnFilter; + const mockFilterChangedArgs = { + columnDef: mockColumn, + columnId: 'gender', + columnFilters: { gender: mockColumnFilter, name: mockColumnFilterName }, + grid: gridStub, + operator: 'EQ', + searchTerms: ['female'], + shouldTriggerQuery: true + } as FilterChangedArgs; + + service.init(serviceOptions, paginationOptions, gridStub); + const query = service.processOnFilterChanged(null, mockFilterChangedArgs); + const currentFilters = service.getCurrentFilters(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(querySpy).toHaveBeenCalled(); + expect(resetSpy).toHaveBeenCalled(); + expect(currentFilters).toEqual([ + { columnId: 'gender', operator: 'EQ', searchTerms: ['female'] }, + { columnId: 'firstName', operator: 'StartsWith', searchTerms: ['John'] } + ]); + }); + }); + + describe('processOnPaginationChanged method', () => { + it('should return a query with the new pagination', () => { + const expectation = `query{users(first:20, offset:40) { totalCount,nodes { id, field1, field2 }}}`; + const querySpy = jest.spyOn(service, 'buildQuery'); + + service.init(serviceOptions, paginationOptions, gridStub); + const query = service.processOnPaginationChanged(null, { newPage: 3, pageSize: 20 }); + const currentPagination = service.getCurrentPagination(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(querySpy).toHaveBeenCalled(); + expect(currentPagination).toEqual({ pageNumber: 3, pageSize: 20 }); + }); + + it('should return a query with the new pagination and use pagination size options that was passed to service options when it is not provided as argument to "processOnPaginationChanged"', () => { + const expectation = `query{users(first:10, offset:20) { totalCount,nodes { id, field1, field2 }}}`; + const querySpy = jest.spyOn(service, 'buildQuery'); + + service.init(serviceOptions, paginationOptions, gridStub); + // @ts-ignore + const query = service.processOnPaginationChanged(null, { newPage: 3 }); + const currentPagination = service.getCurrentPagination(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(querySpy).toHaveBeenCalled(); + expect(currentPagination).toEqual({ pageNumber: 3, pageSize: 10 }); + }); + + it('should return a query with the new pagination and use default pagination size when not provided as argument', () => { + const expectation = `query{users(first:${DEFAULT_PAGE_SIZE}, offset:${DEFAULT_PAGE_SIZE * 2}) { totalCount,nodes { id, field1, field2 }}}`; + const querySpy = jest.spyOn(service, 'buildQuery'); + + service.init(serviceOptions, undefined, gridStub); + // @ts-ignore + const query = service.processOnPaginationChanged(null, { newPage: 3 }); + const currentPagination = service.getCurrentPagination(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(querySpy).toHaveBeenCalled(); + expect(currentPagination).toEqual({ pageNumber: 3, pageSize: 20 }); + }); + }); + + describe('processOnSortChanged method', () => { + it('should return a query with the new sorting when using single sort', () => { + const expectation = `query{ users(first:10, offset:0, orderBy:[{field:gender, direction: DESC}]) { totalCount,nodes{ id,field1,field2 } }}`; + const querySpy = jest.spyOn(service, 'buildQuery'); + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockSortChangedArgs = { columnId: 'gender', sortCol: mockColumn, sortAsc: false, multiColumnSort: false } as ColumnSort; + + service.init(serviceOptions, paginationOptions, gridStub); + const query = service.processOnSortChanged(null, mockSortChangedArgs); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(querySpy).toHaveBeenCalled(); + }); + + it('should return a query with the multiple new sorting when using multiColumnSort', () => { + const expectation = `query{ users(first:10, offset:0, + orderBy:[{field:gender, direction: DESC}, {field:firstName, direction: ASC}]) { + totalCount,nodes{ id,field1,field2 } }}`; + const querySpy = jest.spyOn(service, 'buildQuery'); + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnName = { id: 'firstName', field: 'firstName' } as Column; + const mockColumnSort = { columnId: 'gender', sortCol: mockColumn, sortAsc: false } as ColumnSort; + const mockColumnSortName = { columnId: 'firstName', sortCol: mockColumnName, sortAsc: true } as ColumnSort; + const mockSortChangedArgs = { sortCols: [mockColumnSort, mockColumnSortName], multiColumnSort: true, grid: gridStub } as MultiColumnSort; + + service.init(serviceOptions, paginationOptions, gridStub); + const query = service.processOnSortChanged(null, mockSortChangedArgs); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(querySpy).toHaveBeenCalled(); + }); + }); + + describe('updateFilters method', () => { + beforeEach(() => { + const columns = [{ id: 'company', field: 'company' }, { id: 'gender', field: 'gender' }, { id: 'name', field: 'name' }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + }); + + it('should throw an error when filter columnId is not found to be part of the column definitions', () => { + const mockCurrentFilter = { columnDef: { id: 'city', field: 'city' }, columnId: 'city', operator: 'EQ', searchTerms: ['Boston'] } as CurrentFilter; + service.init(serviceOptions, paginationOptions, gridStub); + expect(() => service.updateFilters([mockCurrentFilter], true)).toThrowError('[GraphQL Service]: Something went wrong in trying to get the column definition'); + }); + + it('should throw an error when neither "field" nor "name" are being part of the column definition', () => { + // @ts-ignore + const mockColumnFilters = { gender: { columnId: 'gender', columnDef: { id: 'gender' }, searchTerms: ['female'], operator: 'EQ' }, } as ColumnFilters; + service.init(serviceOptions, paginationOptions, gridStub); + expect(() => service.updateFilters(mockColumnFilters, false)).toThrowError('GraphQL filter could not find the field name to query the search'); + }); + + it('should return a query with the new filter when filters are passed as a filter trigger by a filter event and is of type ColumnFilters', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:EQ, value:"female"}]) { + totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['female'], operator: 'EQ' }, + } 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 without filtering when the filter "searchTerms" property is missing from the search', () => { + const expectation = `query{users(first:10, offset:0) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, operator: 'EQ' }, + } 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 multiple filters when the filters object has multiple search and they are passed as a filter trigger by a filter event and is of type ColumnFilters', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:EQ, value:"female"}, {field:company, operator:Not_Contains, value:"abc"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumnGender = { id: 'gender', field: 'gender' } as Column; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumnGender, searchTerms: ['female'], operator: 'EQ' }, + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: OperatorType.notContains }, + } 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 multiple filters and expect same query string result as previous test even with "isUpdatedByPreset" enabled', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:EQ, value:"female"}, {field:company, operator:Contains, value:"abc"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumnGender = { id: 'gender', field: 'gender' } as Column; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumnGender, searchTerms: ['female'], operator: 'EQ' }, + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, true); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + }); + + it('should return a query with the new filter when filters are passed as a Grid Preset of type CurrentFilter', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:EQ, value:"female"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockCurrentFilter = { columnDef: mockColumn, columnId: 'gender', operator: 'EQ', searchTerms: ['female'] } as CurrentFilter; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters([mockCurrentFilter], true); + const query = service.buildQuery(); + const currentFilters = service.getCurrentFilters(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(currentFilters).toEqual([{ columnId: 'gender', operator: 'EQ', searchTerms: ['female'] }]); + }); + + it('should return a query with search having the operator StartsWith when search value has the * symbol as the last character', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:StartsWith, value:"fem"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['fem*'] }, + } 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 EndsWith when search value has the * symbol as the first character', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:EndsWith, value:"le"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['*le'] }, + } 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 EndsWith when the operator was provided as *z', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:EndsWith, value:"le"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['le*'], operator: '*z' }, + } 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 StartsWith even when search value last char is * symbol but the operator provided is *z', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:StartsWith, value:"le"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['le*'], operator: 'a*' }, + } 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 EndsWith when the Column Filter was provided as *z', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:EndsWith, value:"le"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'gender', field: 'gender', filter: { operator: '*z' } } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['le'] }, + } 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 StartsWith when the operator was provided as a*', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:StartsWith, value:"le"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['le'], operator: 'a*' }, + } 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 a range of exclusive numbers when the search value contains 2 (..) to represent a range of numbers', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:duration, operator:GT, value:"2"}, {field:duration, operator:LT, value:"33"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'duration', field: 'duration' } as Column; + const mockColumnFilters = { + duration: { columnId: 'duration', columnDef: mockColumn, searchTerms: ['2..33'] }, + } 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 a range of inclusive numbers when 2 searchTerms numbers are provided and the operator is "RangeInclusive"', () => { + 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 } }}`; + const mockColumn = { id: 'duration', field: 'duration' } as Column; + const mockColumnFilters = { + duration: { columnId: 'duration', columnDef: mockColumn, searchTerms: [2, 33], operator: 'RangeInclusive' }, + } 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 a range of exclusive dates when the search value contains 2 (..) to represent a range of dates', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:startDate, operator:GT, value:"2001-01-01"}, {field:startDate, operator:LT, value:"2001-01-31"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'startDate', field: 'startDate' } as Column; + const mockColumnFilters = { + startDate: { columnId: 'startDate', columnDef: mockColumn, searchTerms: ['2001-01-01..2001-01-31'] }, + } 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 a range of inclusive dates when 2 searchTerms dates are provided and the operator is "RangeInclusive"', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:startDate, operator:GE, value:"2001-01-01"}, {field:startDate, operator:LE, value:"2001-01-31"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'startDate', field: 'startDate' } as Column; + const mockColumnFilters = { + startDate: { columnId: 'startDate', columnDef: mockColumn, searchTerms: ['2001-01-01', '2001-01-31'], operator: 'RangeInclusive' }, + } 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 a CSV string when the filter operator is IN ', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:IN, value:"female,male"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['female', 'male'], operator: 'IN' }, + } 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 a CSV string when the filter operator is NOT_IN', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:NOT_IN, value:"female,male"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['female', 'male'], operator: OperatorType.notIn }, + } 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 a CSV string and use the operator from the Column Definition Operator when provided', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:NOT_IN, value:"female,male"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'gender', field: 'gender', filter: { operator: OperatorType.notIn } } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['female', 'male'] }, + } 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 mapped operator when no operator was provided but we have a column "type" property', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:Contains, value:"le"}, {field:age, operator:EQ, value:"28"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumnGender = { id: 'gender', field: 'gender', type: FieldType.string } as Column; + const mockColumnAge = { id: 'age', field: 'age', type: FieldType.number } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumnGender, searchTerms: ['le'] }, + age: { columnId: 'age', columnDef: mockColumnAge, searchTerms: [28] }, + } 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 mapped operator when neither operator nor column "type" property exists', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:Contains, value:"le"}, {field:city, operator:Contains, value:"Bali"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumnGender = { id: 'gender', field: 'gender' } as Column; + const mockColumnCity = { id: 'city', field: 'city' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumnGender, searchTerms: ['le'] }, + city: { columnId: 'city', columnDef: mockColumnCity, searchTerms: ['Bali'] }, + } 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 the new filter search value of empty string when searchTerms has an undefined value', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:EQ, value:""}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: [undefined], operator: 'EQ' }, + } 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 using a different field to query when the column has a "queryField" defined in its definition', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:isMale, operator:EQ, value:"true"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'gender', field: 'gender', queryField: 'isMale' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: [true], operator: 'EQ' }, + } 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 using a different field to query when the column has a "queryFieldFilter" defined in its definition', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:hasPriority, operator:EQ, value:"female"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'gender', field: 'gender', queryField: 'isAfter', queryFieldFilter: 'hasPriority' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['female'], operator: 'EQ' }, + } 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 using the column "name" property when "field" is not defined in its definition', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:gender, operator:EQ, value:"female"}]) { totalCount,nodes{ id,company,gender,name } }}`; + const mockColumn = { id: 'gender', name: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['female'], operator: 'EQ' }, + } 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 without any sorting after clearFilters was called', () => { + const expectation = `query{ users(first:10,offset:0) { totalCount, nodes {id, company, gender,name} }}`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['female'], operator: 'EQ' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + service.clearFilters(); + const currentFilters = service.getCurrentFilters(); + const query = service.buildQuery(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(currentFilters).toEqual([]); + }); + }); + + describe('presets', () => { + beforeEach(() => { + const columns = [{ id: 'company', field: 'company' }, { id: 'gender', field: 'gender' }, { id: 'duration', field: 'duration' }, { id: 'startDate', field: 'startDate' }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + }); + + it('should return a query with search having a range of exclusive numbers when the search value contains 2 (..) to represent a range of numbers', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:duration, operator:GT, value:"2"}, {field:duration, operator:LT, value:"33"}]) { + totalCount,nodes{ id,company,gender,duration,startDate } }}`; + const presetFilters = [ + { columnId: 'duration', searchTerms: ['2..33'] }, + ] as CurrentFilter[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(presetFilters, true); + const query = service.buildQuery(); + const currentFilters = service.getCurrentFilters(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(currentFilters).toEqual(presetFilters); + }); + + it('should return a query with a filter with range of numbers with decimals when the preset is a filter range with 3 dots (..) separator', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:duration, operator:GT, value:"0.5"}, {field:duration, operator:LT, value:".88"}]) { totalCount,nodes{ id,company,gender,duration,startDate } }}`; + const presetFilters = [ + { columnId: 'duration', searchTerms: ['0.5...88'] }, + ] as CurrentFilter[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(presetFilters, true); + const query = service.buildQuery(); + const currentFilters = service.getCurrentFilters(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(currentFilters).toEqual(presetFilters); + }); + + it('should return a query with search having a range of inclusive numbers when 2 searchTerms numbers are provided and the operator is "RangeInclusive"', () => { + 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,duration,startDate } }}`; + const presetFilters = [ + { columnId: 'duration', searchTerms: [2, 33], operator: 'RangeInclusive' }, + ] as CurrentFilter[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(presetFilters, true); + const query = service.buildQuery(); + const currentFilters = service.getCurrentFilters(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(currentFilters).toEqual(presetFilters); + }); + + it('should return a query with search having a range of exclusive numbers when 2 searchTerms numbers are provided without any operator', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:duration, operator:GT, value:2}, {field:duration, operator:LT, value:33}]) { totalCount,nodes{ id,company,gender,duration,startDate } }}`; + const presetFilters = [ + { columnId: 'duration', searchTerms: [2, 33] }, + ] as CurrentFilter[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(presetFilters, true); + const query = service.buildQuery(); + const currentFilters = service.getCurrentFilters(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(currentFilters).toEqual(presetFilters); + }); + + it('should return a query with search having a range of exclusive dates when the search value contains 2 (..) to represent a range of dates', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:startDate, operator:GT, value:"2001-01-01"}, {field:startDate, operator:LT, value:"2001-01-31"}]) { totalCount,nodes{ id,company,gender,duration,startDate } }}`; + const presetFilters = [ + { columnId: 'startDate', searchTerms: ['2001-01-01..2001-01-31'] }, + ] as CurrentFilter[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(presetFilters, true); + const query = service.buildQuery(); + const currentFilters = service.getCurrentFilters(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(currentFilters).toEqual(presetFilters); + }); + + it('should return a query with search having a range of inclusive dates when 2 searchTerms dates are provided and the operator is "RangeInclusive"', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:startDate, operator:GE, value:"2001-01-01"}, {field:startDate, operator:LE, value:"2001-01-31"}]) { totalCount,nodes{ id,company,gender,duration,startDate } }}`; + const presetFilters = [ + { columnId: 'startDate', searchTerms: ['2001-01-01', '2001-01-31'], operator: 'RangeInclusive' }, + ] as CurrentFilter[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(presetFilters, true); + const query = service.buildQuery(); + const currentFilters = service.getCurrentFilters(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(currentFilters).toEqual(presetFilters); + }); + + it('should return a query with search having a range of exclusive dates when 2 searchTerms dates are provided without any operator', () => { + const expectation = `query{users(first:10, offset:0, filterBy:[{field:startDate, operator:GT, value:"2001-01-01"}, {field:startDate, operator:LT, value:"2001-01-31"}]) { totalCount,nodes{ id,company,gender,duration,startDate } }}`; + const presetFilters = [ + { columnId: 'startDate', searchTerms: ['2001-01-01', '2001-01-31'] }, + ] as CurrentFilter[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(presetFilters, true); + const query = service.buildQuery(); + const currentFilters = service.getCurrentFilters(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(currentFilters).toEqual(presetFilters); + }); + }); + + describe('updateSorters method', () => { + beforeEach(() => { + const columns = [{ id: 'company', field: 'company' }, { id: 'gender', field: 'gender' }, { id: 'name', field: 'name' }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + }); + + it('should return a query with the multiple new sorting when using multiColumnSort', () => { + const expectation = `query{ users(first:10, offset:0, + orderBy:[{field:gender, direction: DESC}, {field:firstName, direction: ASC}]) { + totalCount,nodes{ id, company, gender, name } }}`; + const mockColumnSort = [ + { columnId: 'gender', sortCol: { id: 'gender', field: 'gender' }, sortAsc: false }, + { columnId: 'firstName', sortCol: { id: 'firstName', field: 'firstName' }, sortAsc: true } + ] as ColumnSort[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateSorters(mockColumnSort); + const query = service.buildQuery(); + const currentSorters = service.getCurrentSorters(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(currentSorters).toEqual([{ columnId: 'gender', direction: 'DESC' }, { columnId: 'firstName', direction: 'ASC' }]); + }); + + it('should return a query when using presets array', () => { + const expectation = `query{ users(first:10, offset:0, + orderBy:[{field:company, direction: DESC}, {field:firstName, direction: ASC}]) { + totalCount, nodes{ id, company, gender, name } }}`; + const presets = [ + { columnId: 'company', direction: 'DESC' }, + { columnId: 'firstName', direction: 'ASC' }, + ] as CurrentSorter[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateSorters(undefined, presets); + const query = service.buildQuery(); + const currentSorters = service.getCurrentSorters(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(currentSorters).toEqual(presets); + }); + + it('should return a query string using a different field to query when the column has a "queryField" defined in its definition', () => { + const expectation = `query{ users(first:10, offset:0, + orderBy:[{field:gender, direction: DESC}, {field:firstName, direction: ASC}]) { + totalCount,nodes{ id, company, gender, name } }}`; + const mockColumnSort = [ + { columnId: 'gender', sortCol: { id: 'gender', field: 'gender' }, sortAsc: false }, + { columnId: 'name', sortCol: { id: 'name', field: 'name', queryField: 'firstName' }, sortAsc: true } + ] as ColumnSort[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateSorters(mockColumnSort); + const query = service.buildQuery(); + const currentSorters = service.getCurrentSorters(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(currentSorters).toEqual([{ columnId: 'gender', direction: 'DESC' }, { columnId: 'name', direction: 'ASC' }]); + }); + + it('should return a query string using a different field to query when the column has a "queryFieldSorter" defined in its definition', () => { + const expectation = `query{ users(first:10, offset:0, + orderBy:[{field:gender, direction: DESC}, {field:lastName, direction: ASC}]) { + totalCount,nodes{ id, company, gender, name } }}`; + const mockColumnSort = [ + { columnId: 'gender', sortCol: { id: 'gender', field: 'gender' }, sortAsc: false }, + { columnId: 'name', sortCol: { id: 'name', field: 'name', queryField: 'isAfter', queryFieldSorter: 'lastName' }, sortAsc: true } + ] as ColumnSort[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateSorters(mockColumnSort); + const query = service.buildQuery(); + const currentSorters = service.getCurrentSorters(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(currentSorters).toEqual([{ columnId: 'gender', direction: 'DESC' }, { columnId: 'name', direction: 'ASC' }]); + }); + + it('should return a query without the field sorter when its field property is missing', () => { + const expectation = `query { users(first:10, offset:0, orderBy:[{field:gender, direction:DESC}]) { + totalCount, nodes { id,company,gender,name }}}`; + const mockColumnSort = [ + { columnId: 'gender', sortCol: { id: 'gender', field: 'gender' }, sortAsc: false }, + { columnId: 'firstName', sortCol: { id: 'firstName' }, sortAsc: true } + ] as ColumnSort[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateSorters(mockColumnSort); + const query = service.buildQuery(); + const currentSorters = service.getCurrentSorters(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(currentSorters).toEqual([{ columnId: 'gender', direction: 'DESC' }, { columnId: 'firstName', direction: 'ASC' }]); + }); + + it('should return a query without any sorting after clearSorters was called', () => { + const expectation = `query { users(first:10, offset:0) { + totalCount, nodes { id,company,gender,name }}}`; + const mockColumnSort = [ + { columnId: 'gender', sortCol: { id: 'gender', field: 'gender' }, sortAsc: false }, + { columnId: 'firstName', sortCol: { id: 'firstName', field: 'firstName' }, sortAsc: true } + ] as ColumnSort[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateSorters(mockColumnSort); + service.clearSorters(); + const query = service.buildQuery(); + const currentSorters = service.getCurrentSorters(); + + expect(removeSpaces(query)).toBe(removeSpaces(expectation)); + expect(currentSorters).toEqual([]); + }); + }); +}); diff --git a/packages/graphql/src/services/__tests__/graphqlQueryBuilder.spec.ts b/packages/graphql/src/services/__tests__/graphqlQueryBuilder.spec.ts new file mode 100644 index 000000000..8e473d0fb --- /dev/null +++ b/packages/graphql/src/services/__tests__/graphqlQueryBuilder.spec.ts @@ -0,0 +1,203 @@ +import * as moment from 'moment-mini'; +import GraphqlQueryBuilder from '../graphqlQueryBuilder'; + +function removeSpaces(textS) { + return `${textS}`.replace(/\s+/g, ''); +} + +describe('GraphqlQueryBuilder', () => { + it('should accept a single find value', () => { + const expectation = `user{age}`; + const user = new GraphqlQueryBuilder('user').find('age'); + + expect(removeSpaces(expectation)).toBe(removeSpaces(user)); + }); + + it('should create a Query with function name & alia', () => { + const expectation = `sam: user{name}`; + const user = new GraphqlQueryBuilder('user', 'sam').find('name'); + + expect(removeSpaces(expectation)).toBe(removeSpaces(user)); + }); + + it('should create a Query with function name & input', () => { + const expectation = `user(id:12345){name}`; + const user = new GraphqlQueryBuilder('user', { id: 12345 }).find('name'); + + expect(removeSpaces(expectation)).toBe(removeSpaces(user)); + }); + + it('should create a Query with function name & input(s)', () => { + const expectation = `user(id:12345, age:34){name}`; + const user = new GraphqlQueryBuilder('user', { id: 12345, age: 34 }).find('name'); + + expect(removeSpaces(expectation)).toBe(removeSpaces(user)); + }); + + it('should accept a single find value with alia', () => { + const expectation = `user{nickname:name}`; + const user = new GraphqlQueryBuilder('user').find({ nickname: 'name' }); + + expect(removeSpaces(expectation)).toBe(removeSpaces(user)); + }); + + it('should accept a multiple find values', () => { + const expectation = `user{firstname, lastname}`; + const user = new GraphqlQueryBuilder('user').find('firstname', 'lastname'); + + expect(removeSpaces(expectation)).toBe(removeSpaces(user)); + }); + + it('should accept an array find values', () => { + const expectation = `user{firstname, lastname}`; + const user = new GraphqlQueryBuilder('user').find(['firstname', 'lastname']); + + expect(removeSpaces(expectation)).toBe(removeSpaces(user)); + }); + + it('should work with nesting Querys', () => { + const expectation = `user( id:12345 ) { + id, nickname : name,isViewerFriend, + image : profilePicture( size:50 ) { + uri, width, height } }`; + + const profilePicture = new GraphqlQueryBuilder('profilePicture', { size: 50 }); + profilePicture.find('uri', 'width', 'height'); + + const user = new GraphqlQueryBuilder('user', { id: 12345 }); + user.find(['id', { 'nickname': 'name' }, 'isViewerFriend', { 'image': profilePicture }]); + + expect(removeSpaces(expectation)).toBe(removeSpaces(user)); + }); + + it('should work with simple nesting Querys', () => { + const expectation = `user { profilePicture { uri, width, height } }`; + + const user = new GraphqlQueryBuilder('user'); + user.find({ 'profilePicture': ['uri', 'width', 'height'] }); + + expect(removeSpaces(expectation)).toBe(removeSpaces(user)); + }); + + it('should be able to Query a Date field', () => { + const now = new Date(); + const expectation = `FetchLeeAndSam { lee: user(modified: "${moment(now).toISOString()}") { name, modified }, + sam: user(modified: "${moment(now).toISOString()}") { name, modified } }`; + + const fetchLeeAndSam = new GraphqlQueryBuilder('FetchLeeAndSam'); + + const lee = new GraphqlQueryBuilder('user', { modified: now }); + lee.setAlias('lee'); + lee.find(['name', 'modified']); + + const sam = new GraphqlQueryBuilder('user', 'sam'); + sam.filter({ modified: now }); + sam.find(['name', 'modified']); + + fetchLeeAndSam.find(lee, sam); + + expect(removeSpaces(fetchLeeAndSam)).toBe(removeSpaces(expectation)); + }); + + it('should be able to group Querys', () => { + const expectation = `FetchLeeAndSam { lee: user(id: "1") { name }, sam: user(id: "2") { name } }`; + + const fetchLeeAndSam = new GraphqlQueryBuilder('FetchLeeAndSam'); + + const lee = new GraphqlQueryBuilder('user', { id: '1' }); + lee.setAlias('lee'); + lee.find(['name']); + + const sam = new GraphqlQueryBuilder('user', 'sam'); + sam.filter({ id: '2' }); + sam.find('name'); + + fetchLeeAndSam.find(lee, sam); + + expect(removeSpaces(fetchLeeAndSam)).toBe(removeSpaces(expectation)); + }); + + it('should work with nasted objects and lists', () => { + const expectation = `myPost:Message(type:"chat",message:"yoyo", + user:{name:"bob",screen:{ height:1080, width:1920}}, + friends:[{id:1, name:"ann"},{id:2, name:"tom"}]) { + messageId: id, postedTime: createTime }`; + + const messageRequest = { + type: 'chat', + message: 'yoyo', + user: { + name: 'bob', + screen: { height: 1080, width: 1920 } + }, + friends: [{ id: 1, name: 'ann' }, { id: 2, name: 'tom' }] + }; + + const messageQuery = new GraphqlQueryBuilder('Message', 'myPost'); + messageQuery.filter(messageRequest); + messageQuery.find({ messageId: 'id' }, { postedTime: 'createTime' }); + + expect(removeSpaces(messageQuery)).toBe(removeSpaces(expectation)); + }); + + it('should work with objects that have help functions(will skip function name)', () => { + const expectation = 'inventory(toy:"jack in the box") { id }'; + const childsToy = { toy: 'jack in the box', getState: () => { } }; + + childsToy.getState(); // for istanbul(coverage) to say all fn was called + const itemQuery = new GraphqlQueryBuilder('inventory', childsToy); + itemQuery.find('id'); + + expect(removeSpaces(itemQuery)).toBe(removeSpaces(expectation)); + }); + + it('should work with nasted objects that have help functions(will skip function name)', () => { + const expectation = 'inventory(toy:"jack in the box") { id }'; + const childsToy = { toy: 'jack in the box', utils: { getState: () => { } } }; + + childsToy.utils.getState(); // for istanbul(coverage) to say all fn was called + const itemQuery = new GraphqlQueryBuilder('inventory', childsToy); + itemQuery.find('id'); + + expect(removeSpaces(itemQuery)).toBe(removeSpaces(expectation)); + }); + + it('should skip empty objects in filter/args', () => { + const expectation = 'inventory(toy:"jack in the box") { id }'; + const childsToy = { toy: 'jack in the box', utils: {} }; + + const itemQuery = new GraphqlQueryBuilder('inventory', childsToy); + itemQuery.find('id'); + + expect(removeSpaces(itemQuery)).toBe(removeSpaces(expectation)); + }); + + it('should throw Error if find input items have zero props', () => { + expect(() => new GraphqlQueryBuilder('x').find({})).toThrow(); + }); + + it('should throw Error if find input items have multiple props', () => { + expect(() => new GraphqlQueryBuilder('x').find({ a: 'z', b: 'y' })).toThrow(); + }); + + it('should throw Error if find is undefined', () => { + expect(() => new GraphqlQueryBuilder('x').find()).toThrow(); + }); + + it('should throw Error if no find values have been set', () => { + expect(() => `${new GraphqlQueryBuilder('x')}`).toThrow(); + }); + + it('should throw Error if find is not valid', () => { + expect(() => new GraphqlQueryBuilder('x').find(123)).toThrow(); + }); + + it('should throw Error if you accidentally pass an undefined', () => { + expect(() => new GraphqlQueryBuilder('x', undefined)).toThrow(); + }); + + it('should throw Error it is not an input object for alias', () => { + // @ts-ignore: 2345 + expect(() => new GraphqlQueryBuilder('x', true)).toThrow(); + }); +}); diff --git a/packages/graphql/src/services/graphql.service.ts b/packages/graphql/src/services/graphql.service.ts new file mode 100644 index 000000000..9f41c5e4f --- /dev/null +++ b/packages/graphql/src/services/graphql.service.ts @@ -0,0 +1,614 @@ +import { + // utilities + mapOperatorType, + mapOperatorByFieldType, + + // enums/interfaces + BackendService, + Column, + ColumnFilter, + ColumnFilters, + ColumnSort, + CurrentFilter, + CurrentPagination, + CurrentSorter, + FieldType, + FilterChangedArgs, + GridOption, + MultiColumnSort, + OperatorString, + OperatorType, + Pagination, + PaginationChangedArgs, + SortDirection, + SortDirectionString, +} from '@slickgrid-universal/common'; +import { + GraphqlCursorPaginationOption, + GraphqlDatasetFilter, + GraphqlFilteringOption, + GraphqlPaginationOption, + GraphqlServiceOption, + GraphqlSortingOption, +} from '../interfaces/index'; + +import QueryBuilder from './graphqlQueryBuilder'; + +const DEFAULT_ITEMS_PER_PAGE = 25; +const DEFAULT_PAGE_SIZE = 20; + +export class GraphqlService implements BackendService { + private _currentFilters: ColumnFilters | CurrentFilter[] = []; + private _currentPagination: CurrentPagination | null; + private _currentSorters: CurrentSorter[] = []; + private _columnDefinitions: Column[]; + private _grid: any; + private _datasetIdPropName = 'id'; + options: GraphqlServiceOption; + pagination: Pagination | undefined; + defaultPaginationOptions: GraphqlPaginationOption | GraphqlCursorPaginationOption = { + first: DEFAULT_ITEMS_PER_PAGE, + offset: 0 + }; + + /** Getter for the Column Definitions */ + get columnDefinitions() { + return this._columnDefinitions; + } + + /** Getter for the Grid Options pulled through the Grid Object */ + private get _gridOptions(): GridOption { + return (this._grid && this._grid.getOptions) ? this._grid.getOptions() : {}; + } + + /** Initialization of the service, which acts as a constructor */ + init(serviceOptions?: GraphqlServiceOption, pagination?: Pagination, grid?: any): void { + this._grid = grid; + this.options = serviceOptions || { datasetName: '' }; + this.pagination = pagination; + this._datasetIdPropName = this._gridOptions.datasetIdPropertyName || 'id'; + + if (grid && grid.getColumns) { + this._columnDefinitions = grid.getColumns() || []; + } + } + + /** + * Build the GraphQL query, since the service include/exclude cursor, the output query will be different. + * @param serviceOptions GraphqlServiceOption + */ + buildQuery() { + if (!this.options || !this.options.datasetName || !Array.isArray(this._columnDefinitions)) { + throw new Error('GraphQL Service requires the "datasetName" property to properly build the GraphQL query'); + } + + // get the column definitions and exclude some if they were tagged as excluded + let columnDefinitions = this._columnDefinitions || []; + columnDefinitions = columnDefinitions.filter((column: Column) => !column.excludeFromQuery); + + const queryQb = new QueryBuilder('query'); + const datasetQb = new QueryBuilder(this.options.datasetName); + const nodesQb = new QueryBuilder('nodes'); + + // get all the columnds Ids for the filters to work + const columnIds: string[] = []; + if (columnDefinitions && Array.isArray(columnDefinitions)) { + for (const column of columnDefinitions) { + columnIds.push(column.field); + + // if extra "fields" are passed, also push them to columnIds + if (column.fields) { + columnIds.push(...column.fields); + } + } + } + + // Slickgrid also requires the "id" field to be part of DataView + // add it to the GraphQL query if it wasn't already part of the list + if (columnIds.indexOf(this._datasetIdPropName) === -1) { + columnIds.unshift(this._datasetIdPropName); + } + + const columnsQuery = this.buildFilterQuery(columnIds); + let graphqlNodeFields = []; + + if (this._gridOptions.enablePagination !== false) { + if (this.options.isWithCursor) { + // ...pageInfo { hasNextPage, endCursor }, edges { cursor, node { _columns_ } }, totalCount: 100 + const edgesQb = new QueryBuilder('edges'); + const pageInfoQb = new QueryBuilder('pageInfo'); + pageInfoQb.find('hasNextPage', 'hasPreviousPage', 'endCursor', 'startCursor'); + nodesQb.find(columnsQuery); + edgesQb.find(['cursor']); + graphqlNodeFields = ['totalCount', nodesQb, pageInfoQb, edgesQb]; + } else { + // ...nodes { _columns_ }, totalCount: 100 + nodesQb.find(columnsQuery); + graphqlNodeFields = ['totalCount', nodesQb]; + } + // all properties to be returned by the query + datasetQb.find(graphqlNodeFields); + } else { + // include all columns to be returned + datasetQb.find(columnsQuery); + } + + // add dataset filters, could be Pagination and SortingFilters and/or FieldFilters + let datasetFilters: GraphqlDatasetFilter = {}; + + // only add pagination if it's enabled in the grid options + if (this._gridOptions.enablePagination !== false) { + datasetFilters = { + ...this.options.paginationOptions, + first: ((this.options.paginationOptions && this.options.paginationOptions.first) ? this.options.paginationOptions.first : ((this.pagination && this.pagination.pageSize) ? this.pagination.pageSize : null)) || this.defaultPaginationOptions.first + }; + + if (!this.options.isWithCursor) { + datasetFilters.offset = ((this.options.paginationOptions && this.options.paginationOptions.hasOwnProperty('offset')) ? +this.options.paginationOptions['offset'] : 0); + } + } + + if (this.options.sortingOptions && Array.isArray(this.options.sortingOptions) && this.options.sortingOptions.length > 0) { + // orderBy: [{ field:x, direction: 'ASC' }] + datasetFilters.orderBy = this.options.sortingOptions; + } + if (this.options.filteringOptions && Array.isArray(this.options.filteringOptions) && this.options.filteringOptions.length > 0) { + // filterBy: [{ field: date, operator: '>', value: '2000-10-10' }] + datasetFilters.filterBy = this.options.filteringOptions; + } + if (this.options.addLocaleIntoQuery) { + // first: 20, ... locale: "en-CA" + datasetFilters.locale = this._gridOptions.i18n && this._gridOptions.i18n.getCurrentLocale() || this._gridOptions.locale || 'en'; + } + if (this.options.extraQueryArguments) { + // first: 20, ... userId: 123 + for (const queryArgument of this.options.extraQueryArguments) { + datasetFilters[queryArgument.field] = queryArgument.value; + } + } + + // with pagination:: query { users(first: 20, offset: 0, orderBy: [], filterBy: []) { totalCount: 100, nodes: { _columns_ }}} + // without pagination:: query { users(orderBy: [], filterBy: []) { _columns_ }} + datasetQb.filter(datasetFilters); + queryQb.find(datasetQb); + + const enumSearchProperties = ['direction:', 'field:', 'operator:']; + return this.trimDoubleQuotesOnEnumField(queryQb.toString(), enumSearchProperties, this.options.keepArgumentFieldDoubleQuotes || false); + } + + /** + * From an input array of strings, we want to build a GraphQL query string. + * The process has to take the dot notation and parse it into a valid GraphQL query + * Following this SO answer https://stackoverflow.com/a/47705476/1212166 + * + * INPUT + * ['firstName', 'lastName', 'billing.address.street', 'billing.address.zip'] + * OUTPUT + * firstName, lastName, billing{address{street, zip}} + * @param inputArray + */ + buildFilterQuery(inputArray: string[]) { + + const set = (o: any = {}, a: any) => { + const k = a.shift(); + o[k] = a.length ? set(o[k], a) : null; + return o; + }; + + const output = inputArray.reduce((o: any, a: string) => set(o, a.split('.')), {}); + + return JSON.stringify(output) + .replace(/\"|\:|null/g, '') + .replace(/^\{/, '') + .replace(/\}$/, ''); + } + + clearFilters() { + this._currentFilters = []; + this.updateOptions({ filteringOptions: [] }); + } + + clearSorters() { + this._currentSorters = []; + this.updateOptions({ sortingOptions: [] }); + } + + /** + * Get an initialization of Pagination options + * @return Pagination Options + */ + getInitPaginationOptions(): GraphqlDatasetFilter { + const paginationFirst = this.pagination ? this.pagination.pageSize : DEFAULT_ITEMS_PER_PAGE; + return (this.options.isWithCursor) ? { first: paginationFirst } : { first: paginationFirst, offset: 0 }; + } + + /** Get the GraphQL dataset name */ + getDatasetName(): string { + return this.options.datasetName || ''; + } + + /** Get the Filters that are currently used by the grid */ + getCurrentFilters(): ColumnFilters | CurrentFilter[] { + return this._currentFilters; + } + + /** Get the Pagination that is currently used by the grid */ + getCurrentPagination(): CurrentPagination | null { + return this._currentPagination; + } + + /** Get the Sorters that are currently used by the grid */ + getCurrentSorters(): CurrentSorter[] { + return this._currentSorters; + } + + /* + * Reset the pagination options + */ + resetPaginationOptions() { + let paginationOptions: GraphqlPaginationOption | GraphqlCursorPaginationOption; + + if (this.options && this.options.isWithCursor) { + // first, last, after, before + paginationOptions = { + after: '', + before: undefined, + last: undefined + } as GraphqlCursorPaginationOption; + } else { + // first, last, offset + paginationOptions = ((this.options && this.options.paginationOptions) || this.getInitPaginationOptions()) as GraphqlPaginationOption; + (paginationOptions as GraphqlPaginationOption).offset = 0; + } + + // save current pagination as Page 1 and page size as "first" set size + this._currentPagination = { + pageNumber: 1, + pageSize: paginationOptions.first || DEFAULT_PAGE_SIZE + }; + + // unless user specifically set "enablePagination" to False, we'll update pagination options in every other cases + if (this._gridOptions && (this._gridOptions.enablePagination || !this._gridOptions.hasOwnProperty('enablePagination'))) { + this.updateOptions({ paginationOptions }); + } + } + + updateOptions(serviceOptions?: Partial) { + this.options = { ...this.options, ...serviceOptions }; + } + + /* + * FILTERING + */ + processOnFilterChanged(event: Event, args: FilterChangedArgs): string { + const gridOptions: GridOption = this._gridOptions; + const backendApi = gridOptions.backendServiceApi; + + if (backendApi === undefined) { + throw new Error('Something went wrong in the GraphqlService, "backendServiceApi" is not initialized'); + } + + // keep current filters & always save it as an array (columnFilters can be an object when it is dealt by SlickGrid Filter) + this._currentFilters = this.castFilterToColumnFilters(args.columnFilters); + + if (!args || !args.grid) { + throw new Error('Something went wrong when trying create the GraphQL Backend Service, it seems that "args" is not populated correctly'); + } + + // loop through all columns to inspect filters & set the query + this.updateFilters(args.columnFilters, false); + + this.resetPaginationOptions(); + return this.buildQuery(); + } + + /* + * PAGINATION + * With cursor, the query can have 4 arguments (first, after, last, before), for example: + * users (first:20, after:"YXJyYXljb25uZWN0aW9uOjM=") { + * totalCount + * pageInfo { + * hasNextPage + * hasPreviousPage + * endCursor + * startCursor + * } + * edges { + * cursor + * node { + * name + * gender + * } + * } + * } + * Without cursor, the query can have 3 arguments (first, last, offset), for example: + * users (first:20, offset: 10) { + * totalCount + * nodes { + * name + * gender + * } + * } + */ + processOnPaginationChanged(event: Event, args: PaginationChangedArgs): string { + const pageSize = +(args.pageSize || ((this.pagination) ? this.pagination.pageSize : DEFAULT_PAGE_SIZE)); + this.updatePagination(args.newPage, pageSize); + + // build the GraphQL query which we will use in the WebAPI callback + return this.buildQuery(); + } + + /* + * SORTING + * we will use sorting as per a Facebook suggestion on a Github issue (with some small changes) + * https://github.com/graphql/graphql-relay-js/issues/20#issuecomment-220494222 + * + * users (first: 20, offset: 10, orderBy: [{field: lastName, direction: ASC}, {field: firstName, direction: DESC}]) { + * totalCount + * nodes { + * name + * gender + * } + * } + */ + processOnSortChanged(event: Event, args: ColumnSort | MultiColumnSort): string { + const sortColumns = (args.multiColumnSort) ? (args as MultiColumnSort).sortCols : new Array({ sortCol: (args as ColumnSort).sortCol, sortAsc: (args as ColumnSort).sortAsc }); + + // loop through all columns to inspect sorters & set the query + this.updateSorters(sortColumns); + + // build the GraphQL query which we will use in the WebAPI callback + return this.buildQuery(); + } + + /** + * loop through all columns to inspect filters & update backend service filteringOptions + * @param columnFilters + */ + updateFilters(columnFilters: ColumnFilters | CurrentFilter[], isUpdatedByPresetOrDynamically: boolean) { + const searchByArray: GraphqlFilteringOption[] = []; + let searchValue: string | string[]; + + // on filter preset load, we need to keep current filters + if (isUpdatedByPresetOrDynamically) { + this._currentFilters = this.castFilterToColumnFilters(columnFilters); + } + + for (const columnId in columnFilters) { + if (columnFilters.hasOwnProperty(columnId)) { + const columnFilter = columnFilters[columnId]; + + // if user defined some "presets", then we need to find the filters from the column definitions instead + let columnDef: Column | undefined; + if (isUpdatedByPresetOrDynamically && Array.isArray(this._columnDefinitions)) { + columnDef = this._columnDefinitions.find((column: Column) => column.id === columnFilter.columnId); + } else { + columnDef = columnFilter.columnDef; + } + if (!columnDef) { + throw new Error('[GraphQL Service]: Something went wrong in trying to get the column definition of the specified filter (or preset filters). Did you make a typo on the filter columnId?'); + } + + const fieldName = columnDef.queryFieldFilter || columnDef.queryField || columnDef.field || columnDef.name || ''; + let searchTerms = columnFilter && columnFilter.searchTerms || []; + let fieldSearchValue = (Array.isArray(searchTerms) && searchTerms.length === 1) ? searchTerms[0] : ''; + if (typeof fieldSearchValue === 'undefined') { + fieldSearchValue = ''; + } + + if (!fieldName) { + throw new Error(`GraphQL filter could not find the field name to query the search, your column definition must include a valid "field" or "name" (optionally you can also use the "queryfield" or "queryFieldFilter").`); + } + + fieldSearchValue = '' + fieldSearchValue; // make sure it's a string + const matches = fieldSearchValue.match(/^([<>!=\*]{0,2})(.*[^<>!=\*])([\*]?)$/); // group 1: Operator, 2: searchValue, 3: last char is '*' (meaning starts with, ex.: abc*) + let operator: OperatorString = columnFilter.operator || ((matches) ? matches[1] : ''); + searchValue = (!!matches) ? matches[2] : ''; + const lastValueChar = (!!matches) ? matches[3] : (operator === '*z' ? '*' : ''); + + // no need to query if search value is empty + if (fieldName && searchValue === '' && searchTerms.length === 0) { + continue; + } + + if (Array.isArray(searchTerms) && searchTerms.length === 1 && typeof searchTerms[0] === 'string' && searchTerms[0].indexOf('..') > 0) { + searchTerms = searchTerms[0].split('..'); + if (!operator) { + operator = OperatorType.rangeExclusive; + } + } + + if (typeof searchValue === 'string') { + // escaping the search value + searchValue = searchValue.replace(`'`, `''`); // escape single quotes by doubling them + 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; + } + + // when having more than 1 search term (we need to create a CSV string for GraphQL "IN" or "NOT IN" filter search) + if (searchTerms && searchTerms.length > 1 && (operator === 'IN' || operator === 'NIN' || operator === 'NOT_IN')) { + searchValue = searchTerms.join(','); + } else if (searchTerms && searchTerms.length === 2 && (!operator || operator === OperatorType.rangeExclusive || operator === OperatorType.rangeInclusive)) { + if (!operator) { + operator = OperatorType.rangeExclusive; + } + searchByArray.push({ field: fieldName, operator: (operator === OperatorType.rangeInclusive ? 'GE' : 'GT'), value: searchTerms[0] }); + searchByArray.push({ field: 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 by it's field type + if (!operator) { + operator = mapOperatorByFieldType(columnDef.type || FieldType.string); + } + + // build the search array + searchByArray.push({ field: fieldName, operator: mapOperatorType(operator), value: searchValue }); + } + } + + // update the service options with filters for the buildQuery() to work later + this.updateOptions({ filteringOptions: searchByArray }); + } + + /** + * Update the pagination component with it's new page number and size + * @param newPage + * @param pageSize + */ + updatePagination(newPage: number, pageSize: number) { + this._currentPagination = { + pageNumber: newPage, + pageSize + }; + + let paginationOptions; + if (this.options && this.options.isWithCursor) { + paginationOptions = { + first: pageSize + }; + } else { + paginationOptions = { + first: pageSize, + offset: (newPage > 1) ? ((newPage - 1) * pageSize) : 0 // recalculate offset but make sure the result is always over 0 + }; + } + + this.updateOptions({ paginationOptions }); + } + + /** + * loop through all columns to inspect sorters & update backend service sortingOptions + * @param columnFilters + */ + updateSorters(sortColumns?: ColumnSort[], presetSorters?: CurrentSorter[]) { + let currentSorters: CurrentSorter[] = []; + const graphqlSorters: GraphqlSortingOption[] = []; + + if (!sortColumns && presetSorters) { + // make the presets the current sorters, also make sure that all direction are in uppercase for GraphQL + currentSorters = presetSorters; + currentSorters.forEach((sorter) => sorter.direction = sorter.direction.toUpperCase() as SortDirectionString); + + // display the correct sorting icons on the UI, for that it requires (columnId, sortAsc) properties + const tmpSorterArray = currentSorters.map((sorter) => { + const columnDef = this._columnDefinitions.find((column: Column) => column.id === sorter.columnId); + + graphqlSorters.push({ + field: columnDef ? ((columnDef.queryFieldSorter || columnDef.queryField || columnDef.field) + '') : (sorter.columnId + ''), + direction: sorter.direction + }); + + // return only the column(s) found in the Column Definitions ELSE null + if (columnDef) { + return { + columnId: sorter.columnId, + sortAsc: sorter.direction.toUpperCase() === SortDirection.ASC + }; + } + return null; + }); + + // set the sort icons, but also make sure to filter out null values (that happens when columnDef is not found) + if (Array.isArray(tmpSorterArray)) { + this._grid.setSortColumns(tmpSorterArray.filter((sorter) => sorter)); + } + } else if (sortColumns && !presetSorters) { + // build the orderBy array, it could be multisort, example + // orderBy:[{field: lastName, direction: ASC}, {field: firstName, direction: DESC}] + if (Array.isArray(sortColumns) && sortColumns.length > 0) { + for (const column of sortColumns) { + if (column && column.sortCol) { + currentSorters.push({ + columnId: column.sortCol.id + '', + direction: column.sortAsc ? SortDirection.ASC : SortDirection.DESC + }); + + const fieldName = (column.sortCol.queryFieldSorter || column.sortCol.queryField || column.sortCol.field || '') + ''; + if (fieldName) { + graphqlSorters.push({ + field: fieldName, + direction: column.sortAsc ? SortDirection.ASC : SortDirection.DESC + }); + } + } + } + } + } + + // keep current Sorters and update the service options with the new sorting + this._currentSorters = currentSorters; + this.updateOptions({ sortingOptions: graphqlSorters }); + } + + /** + * A function which takes an input string and removes double quotes only + * on certain fields are identified as GraphQL enums (except fields with dot notation) + * For example let say we identified ("direction:", "sort") as word which are GraphQL enum fields + * then the result will be: + * FROM + * query { users (orderBy:[{field:"firstName", direction:"ASC"} }]) } + * TO + * query { users (orderBy:[{field: firstName, direction: ASC}})} + * + * EXCEPTIONS (fields with dot notation "." which are inside a "field:") + * these fields will keep double quotes while everything else will be stripped of double quotes + * query { users (orderBy:[{field:"billing.street.name", direction: "ASC"} } + * TO + * query { users (orderBy:[{field:"billing.street.name", direction: ASC}} + * @param inputStr input string + * @param enumSearchWords array of enum words to filter + * @returns outputStr output string + */ + trimDoubleQuotesOnEnumField(inputStr: string, enumSearchWords: string[], keepArgumentFieldDoubleQuotes: boolean) { + const patternWordInQuotes = `\s?((field:\s*)?".*?")`; + let patternRegex = enumSearchWords.join(patternWordInQuotes + '|'); + patternRegex += patternWordInQuotes; // the last one should also have the pattern but without the pipe "|" + // example with (field: & direction:): /field:s?(".*?")|direction:s?(".*?")/ + const reg = new RegExp(patternRegex, 'g'); + + return inputStr.replace(reg, (group1, group2, group3) => { + // remove double quotes except when the string starts with a "field:" + let removeDoubleQuotes = true; + if (group1.startsWith('field:') && keepArgumentFieldDoubleQuotes) { + removeDoubleQuotes = false; + } + const rep = removeDoubleQuotes ? group1.replace(/"/g, '') : group1; + return rep; + }); + } + + // + // private functions + // ------------------- + /** + * Cast provided filters (could be in multiple formats) into an array of CurrentFilter + * @param columnFilters + */ + private castFilterToColumnFilters(columnFilters: ColumnFilters | CurrentFilter[]): CurrentFilter[] { + // keep current filters & always save it as an array (columnFilters can be an object when it is dealt by SlickGrid Filter) + const filtersArray: ColumnFilter[] = (typeof columnFilters === 'object') ? Object.keys(columnFilters).map(key => columnFilters[key]) : columnFilters; + + if (!Array.isArray(filtersArray)) { + return []; + } + + return filtersArray.map((filter) => { + const tmpFilter: CurrentFilter = { columnId: filter.columnId || '' }; + if (filter.operator) { + tmpFilter.operator = filter.operator; + } + if (Array.isArray(filter.searchTerms)) { + tmpFilter.searchTerms = filter.searchTerms; + } + return tmpFilter; + }); + } +} diff --git a/packages/graphql/src/services/graphqlQueryBuilder.ts b/packages/graphql/src/services/graphqlQueryBuilder.ts new file mode 100644 index 000000000..b7051f445 --- /dev/null +++ b/packages/graphql/src/services/graphqlQueryBuilder.ts @@ -0,0 +1,141 @@ +/** + * This GraphqlQueryBuilder class is a lib that already exist + * but was causing issues with TypeScript, RequireJS and other bundler/packagers + * and so I rewrote it in pure TypeScript. + * + * The previous lib can be viewed here at this Github + * https://github.com/codemeasandwich/graphql-query-builder + */ +export default class GraphqlQueryBuilder { + // eslint-disable-next-line @typescript-eslint/ban-types + alias: string | Function; + head: any[] = []; + body: any; + + /* Constructor, query/mutator you wish to use, and an alias or filter arguments. */ + // eslint-disable-next-line @typescript-eslint/ban-types + constructor(private queryFnName: string, aliasOrFilter?: string | object) { + if (typeof aliasOrFilter === 'string') { + this.alias = aliasOrFilter; + } else if (typeof aliasOrFilter === 'object') { + this.filter(aliasOrFilter); + } else if (aliasOrFilter === undefined && arguments.length === 2) { + throw new TypeError(`You have passed undefined as Second argument to "Query"`); + } else if (aliasOrFilter !== undefined) { + throw new TypeError(`Second argument to "Query" should be an alias name(String) or filter arguments(Object). What was passed is: ${aliasOrFilter}`); + } + } + + /** + * The parameters to run the query against. + * @param filters An object mapping attribute to values + */ + filter(filters: any) { + for (const prop of Object.keys(filters)) { + if (typeof filters[prop] === 'function') { + continue; + } + const val = this.getGraphQLValue(filters[prop]); + if (val === '{}') { + continue; + } + this.head.push(`${prop}:${val}`); + } + return this; + } + + /** + * Outlines the properties you wish to be returned from the query. + * @param properties representing each attribute you want Returned + */ + find(...searches: any[]) { // THIS NEED TO BE A "FUNCTION" to scope 'arguments' + if (!searches || !Array.isArray(searches) || searches.length === 0) { + throw new TypeError(`find value can not be >>falsy<<`); + } + // if its a string.. it may have other values + // else it sould be an Object or Array of maped values + const searchKeys = (searches.length === 1 && Array.isArray(searches[0])) ? searches[0] : searches; + this.body = this.parceFind(searchKeys); + return this; + } + + /** + * set an alias for this result. + * @param alias + */ + setAlias(alias: string) { + this.alias = alias; + } + + /** + * Return to the formatted query string + * @return + */ + toString() { + if (this.body === undefined) { + throw new ReferenceError(`return properties are not defined. use the 'find' function to defined them`); + } + + return `${(this.alias) ? (this.alias + ':') : ''} ${this.queryFnName} ${(this.head.length > 0) ? '(' + this.head.join(',') + ')' : ''} { ${this.body} }`; + } + + // -- + // PRIVATE FUNCTIONS + // ----------------- + + private parceFind(_levelA: any[]) { + const propsA = _levelA.map((currentValue, index) => { + const itemX = _levelA[index]; + + if (itemX instanceof GraphqlQueryBuilder) { + return itemX.toString(); + } else if (!Array.isArray(itemX) && typeof itemX === 'object') { + const propsAA = Object.keys(itemX); + if (1 !== propsAA.length) { + throw new RangeError(`Alias objects should only have one value. was passed: ${JSON.stringify(itemX)}`); + } + const propS = propsAA[0]; + const item = itemX[propS]; + + if (Array.isArray(item)) { + return new GraphqlQueryBuilder(propS).find(item); + } + return `${propS} : ${item} `; + } else if (typeof itemX === 'string') { + return itemX; + } else { + throw new RangeError(`cannot handle Find value of ${itemX}`); + } + }); + + return propsA.join(','); + } + + private getGraphQLValue(value: any) { + if (typeof value === 'string') { + value = JSON.stringify(value); + } else if (Array.isArray(value)) { + value = value.map(item => { + return this.getGraphQLValue(item); + }).join(); + value = `[${value}]`; + } else if (value instanceof Date) { + value = JSON.stringify(value); + } else if (value !== null && typeof value === 'object') { + value = this.objectToString(value); + } + return value; + } + + private objectToString(obj: any) { + const sourceA = []; + + for (const prop of Object.keys(obj)) { + if (typeof obj[prop] === 'function') { + continue; + } + sourceA.push(`${prop}:${this.getGraphQLValue(obj[prop])}`); + } + return `{${sourceA.join()}}`; + } +} diff --git a/packages/graphql/src/services/index.ts b/packages/graphql/src/services/index.ts new file mode 100644 index 000000000..d46382720 --- /dev/null +++ b/packages/graphql/src/services/index.ts @@ -0,0 +1,2 @@ +export * from './graphql.service'; +export { default as GraphqlQueryBuilder } from './graphqlQueryBuilder'; diff --git a/packages/graphql/tsconfig.build.json b/packages/graphql/tsconfig.build.json new file mode 100644 index 000000000..407988bc5 --- /dev/null +++ b/packages/graphql/tsconfig.build.json @@ -0,0 +1,40 @@ +{ + "compilerOptions": { + "module": "es2020", + "moduleResolution": "node", + "target": "es2015", + "lib": [ + "es2020", + "dom" + ], + "typeRoots": [ + "../typings", + "../../node_modules/@types" + ], + "outDir": "dist/amd", + "noImplicitAny": true, + "suppressImplicitAnyIndexErrors": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "skipLibCheck": true, + "strictNullChecks": true, + "declaration": true, + "forceConsistentCasingInFileNames": true, + "experimentalDecorators": true, + "noEmitHelpers": false, + "stripInternal": true, + "sourceMap": true + }, + "exclude": [ + ".vscode", + "src/examples", + "src/resources", + "test", + "**/*.spec.ts" + ], + "include": [ + "../typings", + "**/*" + ] +} diff --git a/packages/graphql/tsconfig.json b/packages/graphql/tsconfig.json new file mode 100644 index 000000000..41e6266de --- /dev/null +++ b/packages/graphql/tsconfig.json @@ -0,0 +1,41 @@ +{ + "extends": "../tsconfig-build.json", + "compileOnSave": false, + "compilerOptions": { + "rootDir": "src", + "declarationDir": "dist/commonjs", + "outDir": "dist/commonjs", + "target": "es2015", + "module": "esnext", + "sourceMap": true, + "allowSyntheticDefaultImports": true, + "noImplicitReturns": true, + "lib": [ + "es2020", + "dom" + ], + "types": [ + "moment", + "node" + ], + "typeRoots": [ + "node_modules/@types", + "src/typings" + ] + }, + "exclude": [ + "cypress", + "dist", + "node_modules", + "**/*.spec.ts" + ], + "filesGlob": [ + "./src/**/*.ts", + "./test/**/*.ts", + "./custom_typings/**/*.d.ts" + ], + "include": [ + "src/**/*.ts", + "src/typings/**/*.ts" + ] +} diff --git a/packages/odata/README.md b/packages/odata/README.md new file mode 100644 index 000000000..67554803c --- /dev/null +++ b/packages/odata/README.md @@ -0,0 +1,37 @@ +## Grid OData Service +#### @slickgrid-universal/odata + +OData Service to sync a grid with an OData backend server, the service will consider any Filter/Sort and automatically build the necessary OData query string that is sent to your OData backend server. + +### Dependencies +No external dependency + +### Installation +Follow the instruction provided in the main [README](https://github.com/ghiscoding/slickgrid-universal#installation), you can see a demo by looking at the [GitHub Demo](https://ghiscoding.github.io/slickgrid-universal) page. + +### Usage +In order to use the Service, you will need to register it in your grid options via the `registerExternalServices` as shown below. + +##### ViewModel +```ts +import { GridOdataService, OdataServiceApi } from '@slickgrid-universal/odata'; + +export class MyExample { + prepareGrid { + this.gridOptions = { + backendServiceApi: { + service: new GridOdataService(), + options: { + version: 4 // OData v4 + }, + preProcess: () => this.displaySpinner(true), + process: (query) => this.getCustomerApiCall(query), + postProcess: (response) => { + this.displaySpinner(false); + this.getCustomerCallback(response); + } + } as OdataServiceApi + } + } +} +``` diff --git a/packages/odata/package.json b/packages/odata/package.json new file mode 100644 index 000000000..5da47647b --- /dev/null +++ b/packages/odata/package.json @@ -0,0 +1,40 @@ +{ + "name": "@slickgrid-universal/odata", + "version": "0.0.2", + "description": "Grid OData Service to sync a grid with an OData backend server", + "browser": "src/index.ts", + "main": "dist/commonjs/index.js", + "module": "dist/es2015/index.js", + "typings": "dist/commonjs/index.d.ts", + "files": [ + "src", + "dist" + ], + "scripts": { + "build": "cross-env tsc --build", + "build:watch": "cross-env tsc --incremental --watch", + "dev": "run-s build sass:build sass:copy", + "dev:watch": "run-p build:watch", + "bundle:commonjs": "tsc --project tsconfig.build.json --outDir dist/commonjs --module commonjs", + "bundle:es2015": "cross-env tsc --project tsconfig.build.json --outDir dist/es2015 --module es2020 --target es2015", + "bundle:es2020": "cross-env tsc --project tsconfig.build.json --outDir dist/es2020 --module es2015 --target es2020", + "bundle": "npm-run-all bundle:commonjs bundle:es2015 bundle:es2020", + "prebundle": "npm-run-all delete:dist", + "delete:dist": "cross-env rimraf dist" + }, + "author": "Ghislain B.", + "license": "MIT", + "engines": { + "node": ">=12.13.1", + "npm": ">=6.12.1" + }, + "browserslist": [ + "last 2 version", + "> 1%", + "maintained node versions", + "not dead" + ], + "dependencies": { + "@slickgrid-universal/common": "^0.0.2" + } +} diff --git a/packages/odata/src/index.spec.ts b/packages/odata/src/index.spec.ts new file mode 100644 index 000000000..8a7d9b6fc --- /dev/null +++ b/packages/odata/src/index.spec.ts @@ -0,0 +1,16 @@ +import * as entry from './index'; +import * as interfaces from './interfaces/index'; +import * as services from './services/index'; + +describe('Testing OData Package entry point', () => { + it('should have multiple index entries defined', () => { + expect(entry).toBeTruthy(); + expect(interfaces).toBeTruthy(); + expect(services).toBeTruthy(); + }); + + it('should have 2x Services defined', () => { + expect(typeof entry.GridOdataService).toBeTruthy(); + expect(typeof entry.OdataQueryBuilderService).toBeTruthy(); + }); +}); diff --git a/packages/odata/src/index.ts b/packages/odata/src/index.ts new file mode 100644 index 000000000..0622a2033 --- /dev/null +++ b/packages/odata/src/index.ts @@ -0,0 +1,2 @@ +export { GridOdataService } from './services/grid-odata.service'; +export { OdataQueryBuilderService } from './services/odataQueryBuilder.service'; diff --git a/packages/odata/src/interfaces/index.ts b/packages/odata/src/interfaces/index.ts new file mode 100644 index 000000000..2f16aab3d --- /dev/null +++ b/packages/odata/src/interfaces/index.ts @@ -0,0 +1,3 @@ +export * from './odataOption.interface'; +export * from './odataServiceApi.interface'; +export * from './odataSortingOption.interface'; diff --git a/packages/odata/src/interfaces/odataOption.interface.ts b/packages/odata/src/interfaces/odataOption.interface.ts new file mode 100644 index 000000000..86f0ded23 --- /dev/null +++ b/packages/odata/src/interfaces/odataOption.interface.ts @@ -0,0 +1,33 @@ +import { BackendServiceOption, CaseType } from '@slickgrid-universal/common'; + +export interface OdataOption extends BackendServiceOption { + /** What is the casing type to use? Typically that would be 1 of the following 2: camelCase or PascalCase */ + caseType: CaseType; + + /** Add the total count $inlinecount (OData v2) or $count (OData v4) to the OData query */ + enableCount?: boolean; + + /** How many rows to pull? */ + top?: number; + + /** How many rows to skip on the pagination? */ + skip?: number; + + /** (alias to "filter") Filter string (or array of string) that must be a valid OData string */ + filter?: string | string[]; + + /** Filter string (or array of string) that must be a valid OData string */ + filterBy?: any; + + /** What is the separator between each filters? Typically "and", "or" */ + filterBySeparator?: 'and' | 'or'; + + /** Filter queue */ + filterQueue?: any[]; + + /** Sorting string (or array of string) that must be a valid OData string */ + orderBy?: string | string[]; + + /** OData (or any other) version number (the query string is different between versions) */ + version?: number; +} diff --git a/packages/odata/src/interfaces/odataServiceApi.interface.ts b/packages/odata/src/interfaces/odataServiceApi.interface.ts new file mode 100644 index 000000000..ae5a490c3 --- /dev/null +++ b/packages/odata/src/interfaces/odataServiceApi.interface.ts @@ -0,0 +1,11 @@ +import { BackendServiceApi } from '@slickgrid-universal/common'; +import { OdataOption } from './odataOption.interface'; +import { GridOdataService } from '../services'; + +export interface OdataServiceApi extends BackendServiceApi { + /** Backend Service Options */ + options?: Partial; + + /** Backend Service instance (could be OData or GraphQL Service) */ + service: GridOdataService; +} diff --git a/packages/odata/src/interfaces/odataSortingOption.interface.ts b/packages/odata/src/interfaces/odataSortingOption.interface.ts new file mode 100644 index 000000000..107d0bc56 --- /dev/null +++ b/packages/odata/src/interfaces/odataSortingOption.interface.ts @@ -0,0 +1,6 @@ +import { SortDirection, SortDirectionString } from '@slickgrid-universal/common'; + +export interface OdataSortingOption { + field: string; + direction: SortDirection | SortDirectionString; +} diff --git a/packages/odata/src/services/__tests__/grid-odata.service.spec.ts b/packages/odata/src/services/__tests__/grid-odata.service.spec.ts new file mode 100644 index 000000000..4f6871044 --- /dev/null +++ b/packages/odata/src/services/__tests__/grid-odata.service.spec.ts @@ -0,0 +1,1605 @@ +import { + CaseType, + Column, + ColumnFilter, + ColumnSort, + CurrentFilter, + FilterChangedArgs, + GridOption, + MultiColumnSort, + Pagination, + ColumnFilters, + OperatorType, + FieldType, + CurrentSorter, + SlickGrid, +} from '@slickgrid-universal/common'; +import { GridOdataService } from '../grid-odata.service'; +import { OdataOption } from '../../interfaces/odataOption.interface'; + +const DEFAULT_ITEMS_PER_PAGE = 25; +const DEFAULT_PAGE_SIZE = 20; + +const gridOptionMock = { + enablePagination: true, + defaultFilterRangeOperator: 'RangeExclusive', + backendServiceApi: { + service: undefined, + preProcess: jest.fn(), + process: jest.fn(), + postProcess: jest.fn(), + } +} as GridOption; + +const gridStub = { + autosizeColumns: jest.fn(), + getColumnIndex: jest.fn(), + getScrollbarDimensions: jest.fn(), + getColumns: jest.fn(), + getOptions: () => gridOptionMock, + setColumns: jest.fn(), + registerPlugin: jest.fn(), + setSelectedRows: jest.fn(), + setSortColumns: jest.fn(), +} as unknown as SlickGrid; + +describe('GridOdataService', () => { + let mockColumns: Column[]; + let service: GridOdataService; + let paginationOptions: Pagination; + let serviceOptions: OdataOption; + + beforeEach(() => { + mockColumns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + service = new GridOdataService(); + serviceOptions = { + orderBy: '', + top: 10, + caseType: CaseType.pascalCase + }; + paginationOptions = { + pageNumber: 1, + pageSizes: [5, 10, 25, 50, 100], + pageSize: 10, + totalItems: 100 + }; + gridOptionMock.enablePagination = true; + gridOptionMock.backendServiceApi.service = service; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create the service', () => { + expect(service).toBeTruthy(); + }); + + describe('init method', () => { + it('should initialize the service and expect the service options and pagination to be set', () => { + service.init(serviceOptions, paginationOptions, gridStub); + expect(service.options).toEqual(serviceOptions); + expect(service.pagination).toEqual(paginationOptions); + }); + + it('should get the column definitions from "getColumns"', () => { + const columns = [{ id: 'field4', field: 'field4', width: 50 }, { id: 'field2', field: 'field2', width: 50 }]; + const spy = jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init(null, paginationOptions, gridStub); + + expect(spy).toHaveBeenCalled(); + expect(service.columnDefinitions).toEqual(columns); + }); + }); + + describe('buildQuery method', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should return a simple query with default $top paginations', () => { + const expectation = `$top=10`; + + service.init(serviceOptions, paginationOptions, gridStub); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should use default pagination "$top" option when "paginationOptions" is not provided', () => { + const expectation = `$top=${DEFAULT_ITEMS_PER_PAGE}`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100, excludeFromQuery: true }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init(null, undefined, gridStub); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a simple query with pagination $top and $skip when using "updatePagination" method', () => { + const expectation = `$top=20&$skip=40`; + const columns = []; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init(null, paginationOptions, gridStub); + service.updatePagination(3, 20); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should be able to provide "orderBy" through the "init" and see the query string include the sorting', () => { + const expectation = `$top=20&$skip=40&$orderby=Name desc`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ orderBy: 'Name desc' }, paginationOptions, gridStub); + service.updatePagination(3, 20); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should be able to provide "orderBy" through the "updateOptions" and see the query string include the sorting', () => { + const expectation = `$top=20&$skip=40&$orderby=Name desc`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init(null, paginationOptions, gridStub); + service.updatePagination(3, 20); + service.updateOptions({ orderBy: 'Name desc' }); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should be able to provide "filter" through the "init" and see the query string include the filter', () => { + const expectation = `$top=20&$skip=40&$filter=(IsActive eq true)`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ filterBy: `IsActive eq true` }, paginationOptions, gridStub); + service.updatePagination(3, 20); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should be able to provide "filter" through the "updateOptions" and see the query string include the filter', () => { + const expectation = `$top=20&$skip=40&$filter=(IsActive eq true)`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init(null, paginationOptions, gridStub); + service.updatePagination(3, 20); + service.updateOptions({ filterBy: `IsActive eq true` }); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + describe('set "enablePagination" to False', () => { + beforeEach(() => { + gridOptionMock.enablePagination = false; + }); + + it('should not add a "$top" option when "enablePagination" is set to False', () => { + const expectation = ''; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100, excludeFromQuery: true }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init(null, undefined, gridStub); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should not do anything when calling "updatePagination" method with "enablePagination" set to False', () => { + const expectation = ''; + const columns = []; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init(null, paginationOptions, gridStub); + service.updatePagination(3, 20); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should be able to provide "filter" through the "init" and see the query string include the filter but without pagination querying when "enablePagination" is set to False', () => { + const expectation = `$filter=(IsActive eq true)`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init({ filterBy: `IsActive eq true` }, paginationOptions, gridStub); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should be able to provide "filter" through the "updateOptions" and see the query string include the filter but without pagination querying when "enablePagination" is set to False', () => { + const expectation = `$filter=(IsActive eq true)`; + const columns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + + service.init(null, paginationOptions, gridStub); + service.updatePagination(3, 20); + service.updateOptions({ filterBy: `IsActive eq true` }); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + }); + }); + + describe('clearFilters method', () => { + it('should call "updateOptions" to clear all filters', () => { + const spy = jest.spyOn(service, 'updateFilters'); + service.clearFilters(); + expect(spy).toHaveBeenCalledWith([]); + }); + }); + + describe('clearSorters method', () => { + it('should call "updateOptions" to clear all sorting', () => { + const spy = jest.spyOn(service, 'updateSorters'); + service.clearSorters(); + expect(spy).toHaveBeenCalledWith([]); + }); + }); + + describe('resetPaginationOptions method', () => { + beforeEach(() => { + paginationOptions.pageSize = 20; + }); + + it('should reset the pagination options with default pagination', () => { + const spy = jest.spyOn(service.odataService, 'updateOptions'); + jest.spyOn(gridStub, 'getColumns').mockReturnValue([]); + + service.init(null, paginationOptions); + service.resetPaginationOptions(); + + expect(spy).toHaveBeenCalledWith({ skip: 0 }); + }); + }); + + describe('processOnFilterChanged method', () => { + it('should throw an error when backendService is undefined', () => { + service.init(serviceOptions, paginationOptions, undefined); + // @ts-ignore + expect(() => service.processOnFilterChanged(null, { grid: gridStub })).toThrow(); + }); + + it('should throw an error when grid is undefined', () => { + service.init(serviceOptions, paginationOptions, gridStub); + + // @ts-ignore + expect(() => service.processOnFilterChanged(null, { grid: undefined })) + .toThrowError('Something went wrong when trying create the GridOdataService'); + }); + + it('should return a query with the new filter', () => { + const expectation = `$top=10&$filter=(Gender eq 'female')`; + const querySpy = jest.spyOn(service.odataService, 'buildQuery'); + const resetSpy = jest.spyOn(service, 'resetPaginationOptions'); + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilter = { columnDef: mockColumn, columnId: 'gender', operator: 'EQ', searchTerms: ['female'] } as ColumnFilter; + const mockFilterChangedArgs = { + columnDef: mockColumn, + columnId: 'gender', + columnFilters: { gender: mockColumnFilter }, + grid: gridStub, + operator: 'EQ', + searchTerms: ['female'], + shouldTriggerQuery: true + } as FilterChangedArgs; + + service.init(serviceOptions, paginationOptions, gridStub); + const query = service.processOnFilterChanged(null, mockFilterChangedArgs); + const currentFilters = service.getCurrentFilters(); + + expect(query).toBe(expectation); + expect(querySpy).toHaveBeenCalled(); + expect(resetSpy).toHaveBeenCalled(); + expect(currentFilters).toEqual([{ columnId: 'gender', operator: 'EQ', searchTerms: ['female'] }]); + }); + + it('should return a query with a new filter when previous filters exists', () => { + const expectation = `$top=10&$filter=(Gender eq 'female' and FirstName eq 'John')`; + const querySpy = jest.spyOn(service.odataService, 'buildQuery'); + const resetSpy = jest.spyOn(service, 'resetPaginationOptions'); + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnName = { id: 'firstName', field: 'firstName' } as Column; + const mockColumnFilter = { columnDef: mockColumn, columnId: 'gender', operator: 'EQ', searchTerms: ['female'] } as ColumnFilter; + const mockColumnFilterName = { columnDef: mockColumnName, columnId: 'firstName', operator: 'StartsWith', searchTerms: ['John'] } as ColumnFilter; + const mockFilterChangedArgs = { + columnDef: mockColumn, + columnId: 'gender', + columnFilters: { gender: mockColumnFilter, name: mockColumnFilterName }, + grid: gridStub, + operator: 'EQ', + searchTerms: ['female'], + shouldTriggerQuery: true + } as FilterChangedArgs; + + service.init(serviceOptions, paginationOptions, gridStub); + const query = service.processOnFilterChanged(null, mockFilterChangedArgs); + const currentFilters = service.getCurrentFilters(); + + expect(query).toBe(expectation); + expect(querySpy).toHaveBeenCalled(); + expect(resetSpy).toHaveBeenCalled(); + expect(currentFilters).toEqual([ + { columnId: 'gender', operator: 'EQ', searchTerms: ['female'] }, + { columnId: 'firstName', operator: 'StartsWith', searchTerms: ['John'] } + ]); + }); + + describe('set "enablePagination" to False', () => { + beforeEach(() => { + gridOptionMock.enablePagination = false; + }); + + it('should return a query with the new filter but without pagination when "enablePagination" is set to False', () => { + const expectation = `$filter=(Gender eq 'female')`; + const querySpy = jest.spyOn(service.odataService, 'buildQuery'); + const resetSpy = jest.spyOn(service, 'resetPaginationOptions'); + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilter = { columnDef: mockColumn, columnId: 'gender', operator: 'EQ', searchTerms: ['female'] } as ColumnFilter; + const mockFilterChangedArgs = { + columnDef: mockColumn, + columnId: 'gender', + columnFilters: { gender: mockColumnFilter }, + grid: gridStub, + operator: 'EQ', + searchTerms: ['female'], + shouldTriggerQuery: true + } as FilterChangedArgs; + + service.init(serviceOptions, paginationOptions, gridStub); + const query = service.processOnFilterChanged(null, mockFilterChangedArgs); + const currentFilters = service.getCurrentFilters(); + + expect(query).toBe(expectation); + expect(querySpy).toHaveBeenCalled(); + expect(resetSpy).toHaveBeenCalled(); + expect(currentFilters).toEqual([{ columnId: 'gender', operator: 'EQ', searchTerms: ['female'] }]); + }); + + it('should return a query with a new filter when previous filters exists when "enablePagination" is set to False', () => { + const expectation = `$filter=(Gender eq 'female' and FirstName eq 'John')`; + const querySpy = jest.spyOn(service.odataService, 'buildQuery'); + const resetSpy = jest.spyOn(service, 'resetPaginationOptions'); + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnName = { id: 'firstName', field: 'firstName' } as Column; + const mockColumnFilter = { columnDef: mockColumn, columnId: 'gender', operator: 'EQ', searchTerms: ['female'] } as ColumnFilter; + const mockColumnFilterName = { columnDef: mockColumnName, columnId: 'firstName', operator: 'StartsWith', searchTerms: ['John'] } as ColumnFilter; + const mockFilterChangedArgs = { + columnDef: mockColumn, + columnId: 'gender', + columnFilters: { gender: mockColumnFilter, name: mockColumnFilterName }, + grid: gridStub, + operator: 'EQ', + searchTerms: ['female'], + shouldTriggerQuery: true + } as FilterChangedArgs; + + service.init(serviceOptions, paginationOptions, gridStub); + const query = service.processOnFilterChanged(null, mockFilterChangedArgs); + const currentFilters = service.getCurrentFilters(); + + expect(query).toBe(expectation); + expect(querySpy).toHaveBeenCalled(); + expect(resetSpy).toHaveBeenCalled(); + expect(currentFilters).toEqual([ + { columnId: 'gender', operator: 'EQ', searchTerms: ['female'] }, + { columnId: 'firstName', operator: 'StartsWith', searchTerms: ['John'] } + ]); + }); + }); + }); + + describe('processOnPaginationChanged method', () => { + it('should return a query with the new pagination', () => { + const expectation = `$top=20&$skip=40`; + const querySpy = jest.spyOn(service.odataService, 'buildQuery'); + + service.init(serviceOptions, paginationOptions, gridStub); + const query = service.processOnPaginationChanged(null, { newPage: 3, pageSize: 20 }); + const currentPagination = service.getCurrentPagination(); + + expect(query).toBe(expectation); + expect(querySpy).toHaveBeenCalled(); + expect(currentPagination).toEqual({ pageNumber: 3, pageSize: 20 }); + }); + + it('should return a query with the new pagination and use pagination size options that was passed to service options when it is not provided as argument to "processOnPaginationChanged"', () => { + const expectation = `$top=10&$skip=20`; + const querySpy = jest.spyOn(service.odataService, 'buildQuery'); + + service.init(serviceOptions, paginationOptions, gridStub); + // @ts-ignore + const query = service.processOnPaginationChanged(null, { newPage: 3 }); + const currentPagination = service.getCurrentPagination(); + + expect(query).toBe(expectation); + expect(querySpy).toHaveBeenCalled(); + expect(currentPagination).toEqual({ pageNumber: 3, pageSize: 10 }); + }); + + it('should return a query with the new pagination and use default pagination size when not provided as argument', () => { + const expectation = `$top=20&$skip=${DEFAULT_PAGE_SIZE * 2}`; + const querySpy = jest.spyOn(service.odataService, 'buildQuery'); + + service.init(serviceOptions, undefined, gridStub); + // @ts-ignore + const query = service.processOnPaginationChanged(null, { newPage: 3 }); + const currentPagination = service.getCurrentPagination(); + + expect(query).toBe(expectation); + expect(querySpy).toHaveBeenCalled(); + expect(currentPagination).toEqual({ pageNumber: 3, pageSize: 20 }); + }); + + it('should return a query without pagination when "enablePagination" is set to False', () => { + gridOptionMock.enablePagination = false; + const expectation = ''; + const querySpy = jest.spyOn(service.odataService, 'buildQuery'); + + service.init(serviceOptions, paginationOptions, gridStub); + const query = service.processOnPaginationChanged(null, { newPage: 3, pageSize: 20 }); + const currentPagination = service.getCurrentPagination(); + + expect(query).toBe(expectation); + expect(querySpy).toHaveBeenCalled(); + expect(currentPagination).toEqual({ pageNumber: 3, pageSize: 20 }); + }); + }); + + describe('processOnSortChanged method', () => { + it('should return a query with the new sorting when using single sort', () => { + const expectation = `$top=10&$orderby=Gender desc`; + const querySpy = jest.spyOn(service.odataService, 'buildQuery'); + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockSortChangedArgs = { columnId: 'gender', sortCol: mockColumn, sortAsc: false, multiColumnSort: false } as ColumnSort; + + service.init(serviceOptions, paginationOptions, gridStub); + const query = service.processOnSortChanged(null, mockSortChangedArgs); + + expect(query).toBe(expectation); + expect(querySpy).toHaveBeenCalled(); + }); + + it('should return a query with the multiple new sorting when using multiColumnSort', () => { + const expectation = `$top=10&$orderby=Gender desc,FirstName asc`; + const querySpy = jest.spyOn(service.odataService, 'buildQuery'); + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnName = { id: 'firstName', field: 'firstName' } as Column; + const mockColumnSort = { columnId: 'gender', sortCol: mockColumn, sortAsc: false } as ColumnSort; + const mockColumnSortName = { columnId: 'firstName', sortCol: mockColumnName, sortAsc: true } as ColumnSort; + const mockSortChangedArgs = { sortCols: [mockColumnSort, mockColumnSortName], multiColumnSort: true, grid: gridStub } as MultiColumnSort; + + service.init(serviceOptions, paginationOptions, gridStub); + const query = service.processOnSortChanged(null, mockSortChangedArgs); + + expect(query).toBe(expectation); + expect(querySpy).toHaveBeenCalled(); + }); + + describe('set "enablePagination" to False', () => { + beforeEach(() => { + gridOptionMock.enablePagination = false; + }); + + it('should return a query with the new sorting when using single sort and without pagintion when "enablePagination" is set to False', () => { + const expectation = `$orderby=Gender desc`; + const querySpy = jest.spyOn(service.odataService, 'buildQuery'); + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockSortChangedArgs = { columnId: 'gender', sortCol: mockColumn, sortAsc: false, multiColumnSort: false } as ColumnSort; + + service.init(serviceOptions, paginationOptions, gridStub); + const query = service.processOnSortChanged(null, mockSortChangedArgs); + + expect(query).toBe(expectation); + expect(querySpy).toHaveBeenCalled(); + }); + + it('should return a query with the multiple new sorting when using multiColumnSort and without pagintion when "enablePagination" is set to False', () => { + const expectation = `$orderby=Gender desc,FirstName asc`; + const querySpy = jest.spyOn(service.odataService, 'buildQuery'); + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnName = { id: 'firstName', field: 'firstName' } as Column; + const mockColumnSort = { columnId: 'gender', sortCol: mockColumn, sortAsc: false } as ColumnSort; + const mockColumnSortName = { columnId: 'firstName', sortCol: mockColumnName, sortAsc: true } as ColumnSort; + const mockSortChangedArgs = { sortCols: [mockColumnSort, mockColumnSortName], multiColumnSort: true, grid: gridStub } as MultiColumnSort; + + service.init(serviceOptions, paginationOptions, gridStub); + const query = service.processOnSortChanged(null, mockSortChangedArgs); + + expect(query).toBe(expectation); + expect(querySpy).toHaveBeenCalled(); + }); + }); + }); + + describe('updateFilters method', () => { + beforeEach(() => { + const columns = [{ id: 'company', field: 'company' }, { id: 'gender', field: 'gender' }, { id: 'name', field: 'name' }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + }); + + it('should throw an error when filter columnId is not found to be part of the column definitions', () => { + const mockCurrentFilter = { columnDef: { id: 'city', field: 'city' }, columnId: 'city', operator: 'EQ', searchTerms: ['Boston'] } as CurrentFilter; + service.init(serviceOptions, paginationOptions, gridStub); + expect(() => service.updateFilters([mockCurrentFilter], true)).toThrowError('[GridOData Service]: Something went wrong in trying to get the column definition'); + }); + + it('should throw an error when neither "field" nor "name" are being part of the column definition', () => { + // @ts-ignore + const mockColumnFilters = { gender: { columnId: 'gender', columnDef: { id: 'gender' }, searchTerms: ['female'], operator: 'EQ' }, } as ColumnFilters; + service.init(serviceOptions, paginationOptions, gridStub); + expect(() => service.updateFilters(mockColumnFilters, false)).toThrowError('GridOData filter could not find the field name to query the search'); + }); + + it('should return a query with the new filter when filters are passed as a filter trigger by a filter event and is of type ColumnFilters', () => { + const expectation = `$top=10&$filter=(Gender eq 'female')`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['female'], operator: 'EQ' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query without filtering when the filter "searchTerms" property is missing from the search', () => { + const expectation = `$top=10`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, operator: 'EQ' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with multiple filters when the filters object has multiple search and they are passed as a filter trigger by a filter event and is of type ColumnFilters', () => { + const expectation = `$top=10&$filter=(Gender eq 'female' and not substringof('abc', Company))`; + const mockColumnGender = { id: 'gender', field: 'gender' } as Column; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumnGender, searchTerms: ['female'], operator: 'EQ' }, + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: OperatorType.notContains }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should escape single quote by doubling the quote when filter includes a single quote', () => { + const expectation = `$top=10&$filter=(Gender eq 'female' and not substringof('abc''s', Company))`; + const mockColumnGender = { id: 'gender', field: 'gender' } as Column; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumnGender, searchTerms: ['female'], operator: 'EQ' }, + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: [`abc's`], operator: OperatorType.notContains }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with multiple filters and expect same query string result as previous test even with "isUpdatedByPreset" enabled', () => { + const expectation = `$top=10&$filter=(Gender eq 'female' and substringof('abc', Company))`; + const mockColumnGender = { id: 'gender', field: 'gender' } as Column; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumnGender, searchTerms: ['female'], operator: 'EQ' }, + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, true); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with the new filter when filters are passed as a Grid Preset of type CurrentFilter', () => { + const expectation = `$top=10&$filter=(Gender eq 'female')`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockCurrentFilter = { columnDef: mockColumn, columnId: 'gender', operator: 'EQ', searchTerms: ['female'] } as CurrentFilter; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters([mockCurrentFilter], true); + const query = service.buildQuery(); + const currentFilters = service.getCurrentFilters(); + + expect(query).toBe(expectation); + expect(currentFilters).toEqual([{ columnId: 'gender', operator: 'EQ', searchTerms: ['female'] }]); + }); + + it('should return a query with search having the operator StartsWith when search value has the * symbol as the last character', () => { + const expectation = `$top=10&$filter=(startswith(Gender, 'fem'))`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['fem*'] }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with search having the operator EndsWith when search value has the * symbol as the first character', () => { + const expectation = `$top=10&$filter=(endswith(Gender, 'le'))`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['*le'] }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with search having the operator EndsWith when the operator was provided as *z', () => { + const expectation = `$top=10&$filter=(endswith(Gender, 'le'))`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['le*'], operator: '*z' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + 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', () => { + const expectation = `$top=10&$filter=(startswith(Gender, 'le'))`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['le*'], operator: 'a*' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with search having the operator EndsWith when the Column Filter was provided as *z', () => { + const expectation = `$top=10&$filter=(endswith(Gender, 'le'))`; + const mockColumn = { id: 'gender', field: 'gender', filter: { operator: '*z' } } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['le'] }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with search having the operator StartsWith when the operator was provided as a*', () => { + const expectation = `$top=10&$filter=(startswith(Gender, 'le'))`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['le'], operator: 'a*' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with a CSV string when the filter operator is IN ', () => { + const expectation = `$top=10&$filter=(Gender eq 'female' or Gender eq 'male')`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['female', 'male'], operator: 'IN' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with a CSV string when the filter operator is IN ', () => { + const expectation = `$top=10&$filter=(Gender eq 'female' or Gender eq 'ma%2Fle')`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['female', 'ma/le'], operator: 'IN' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with a CSV string when the filter operator is IN for numeric column type', () => { + const expectation = `$top=10&$filter=(Id eq 100 or Id eq 101)`; + const mockColumn = { id: 'id', field: 'id', type: FieldType.number } as Column; + const mockColumnFilters = { + gender: { columnId: 'id', columnDef: mockColumn, searchTerms: [100, 101], operator: 'IN' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with a CSV string when the filter operator is NOT_IN', () => { + const expectation = `$top=10&$filter=(Gender ne 'female' and Gender ne 'male')`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['female', 'male'], operator: OperatorType.notIn }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with a CSV string when the filter operator is NOT_IN', () => { + const expectation = `$top=10&$filter=(Gender ne 'female' and Gender ne 'ma%2Fle')`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['female', 'ma/le'], operator: OperatorType.notIn }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with a CSV string and use the operator from the Column Definition Operator when provided', () => { + const expectation = `$top=10&$filter=(Gender ne 'female' and Gender ne 'male')`; + const mockColumn = { id: 'gender', field: 'gender', filter: { operator: OperatorType.notIn } } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['female', 'male'] }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with mapped operator when no operator was provided but we have a column "type" property', () => { + const expectation = `$top=10&$filter=(substringof('le', Gender) and Age eq 28)`; + const mockColumnGender = { id: 'gender', field: 'gender', type: FieldType.string } as Column; + const mockColumnAge = { id: 'age', field: 'age', type: FieldType.number } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumnGender, searchTerms: ['le'] }, + age: { columnId: 'age', columnDef: mockColumnAge, searchTerms: [28] }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with mapped operator when neither operator nor column "type" property exists', () => { + const expectation = `$top=10&$filter=(substringof('le', Gender) and substringof('Bali', City))`; + const mockColumnGender = { id: 'gender', field: 'gender' } as Column; + const mockColumnCity = { id: 'city', field: 'city' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumnGender, searchTerms: ['le'] }, + city: { columnId: 'city', columnDef: mockColumnCity, searchTerms: ['Bali'] }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query without any filters when the "searchTerms" has an undefined value', () => { + const expectation = `$top=10`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: [undefined], operator: 'EQ' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query without any filters when the "searchTerms" has an empty string', () => { + const expectation = `$top=10`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: [''], operator: 'EQ' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query using a different field to query when the column has a "queryField" defined in its definition', () => { + const expectation = `$top=10&$filter=(IsMale eq true)`; + const mockColumn = { id: 'gender', field: 'gender', type: FieldType.boolean, queryField: 'isMale' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: [true], operator: 'EQ' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query using a different field to query when the column has a "queryFieldFilter" defined in its definition', () => { + const expectation = `$top=10&$filter=(HasPriority eq 'female')`; + const mockColumn = { id: 'gender', field: 'gender', queryField: 'isAfter', queryFieldFilter: 'hasPriority' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['female'], operator: 'EQ' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query using the column "name" property when "field" is not defined in its definition', () => { + const expectation = `$top=10&$filter=(Gender eq 'female')`; + const mockColumn = { id: 'gender', name: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['female'], operator: 'EQ' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query without filters when we set the "bypassBackendQuery" flag', () => { + const expectation = `$top=10&$filter=(substringof('abc', Company))`; + const mockColumnGender = { id: 'gender', field: 'gender' } as Column; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumnGender, searchTerms: ['female'], operator: 'EQ', bypassBackendQuery: true }, + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with a date showing as DateTime as per OData requirement', () => { + const expectation = `$top=10&$filter=(substringof('abc', Company) and UpdatedDate eq DateTime'2001-02-28T00:00:00Z')`; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnUpdated = { id: 'updatedDate', field: 'updatedDate', type: FieldType.date } as Column; + const mockColumnFilters = { + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + updatedDate: { columnId: 'updatedDate', columnDef: mockColumnUpdated, searchTerms: ['2001-02-28'], operator: 'EQ' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query without any sorting after clearFilters was called', () => { + const expectation = `$top=10`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['female'], operator: 'EQ' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + service.clearFilters(); + const currentFilters = service.getCurrentFilters(); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + expect(currentFilters).toEqual([]); + }); + + it('should return a query to filter a search value between an inclusive range of numbers using the 2 dots (..) separator and the "RangeInclusive" operator', () => { + const expectation = `$top=10&$filter=(substringof('abc', Company) and (Duration ge 5 and Duration le 22))`; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnDuration = { id: 'duration', field: 'duration', type: FieldType.number } as Column; + const mockColumnFilters = { + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + duration: { columnId: 'duration', columnDef: mockColumnDuration, searchTerms: ['5..22'], operator: 'RangeInclusive' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query to filter a search value between an exclusive range of numbers using 2 search terms and the "RangeExclusive" operator', () => { + const expectation = `$top=10&$filter=(substringof('abc', Company) and (Duration gt 5 and Duration lt 22))`; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnDuration = { id: 'duration', field: 'duration', type: FieldType.number } as Column; + const mockColumnFilters = { + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + duration: { columnId: 'duration', columnDef: mockColumnDuration, searchTerms: [5, 22], operator: 'RangeExclusive' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query to filter a search value between an inclusive range of dates using the 2 dots (..) separator and the "RangeInclusive" operator', () => { + const expectation = `$top=10&$filter=(substringof('abc', Company) and (UpdatedDate ge DateTime'2001-01-20T00:00:00Z' and UpdatedDate le DateTime'2001-02-28T00:00:00Z'))`; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnUpdated = { id: 'updatedDate', field: 'updatedDate', type: FieldType.date } as Column; + const mockColumnFilters = { + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + updatedDate: { columnId: 'updatedDate', columnDef: mockColumnUpdated, searchTerms: ['2001-01-20..2001-02-28'], operator: 'RangeInclusive' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query to filter a search value between an exclusive range of dates using 2 search terms and the "RangeExclusive" operator', () => { + const expectation = `$top=10&$filter=(substringof('abc', Company) and (UpdatedDate gt DateTime'2001-01-20T00:00:00Z' and UpdatedDate lt DateTime'2001-02-28T00:00:00Z'))`; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnUpdated = { id: 'updatedDate', field: 'updatedDate', type: FieldType.date } as Column; + const mockColumnFilters = { + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + updatedDate: { columnId: 'updatedDate', columnDef: mockColumnUpdated, searchTerms: ['2001-01-20', '2001-02-28'], operator: 'RangeExclusive' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + describe('set "enablePagination" to False', () => { + beforeEach(() => { + gridOptionMock.enablePagination = false; + }); + + it('should return a query with the new filter when filters are passed as a filter trigger by a filter event and is of type ColumnFilters but without pagintion when "enablePagination" is set to False', () => { + const expectation = `$filter=(Gender eq 'female')`; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['female'], operator: 'EQ' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query without any sorting neither pagination after clearFilters was called when "enablePagination" is set to False', () => { + const expectation = ''; + const mockColumn = { id: 'gender', field: 'gender' } as Column; + const mockColumnFilters = { + gender: { columnId: 'gender', columnDef: mockColumn, searchTerms: ['female'], operator: 'EQ' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + service.clearFilters(); + const currentFilters = service.getCurrentFilters(); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + expect(currentFilters).toEqual([]); + }); + + it('should return a query to filter a search value between an inclusive range of numbers using the 2 dots (..) separator and the "RangeInclusive" operator without pagination hen "enablePagination" is set to False', () => { + const expectation = `$filter=(substringof('abc', Company) and (Duration ge 5 and Duration le 22))`; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnDuration = { id: 'duration', field: 'duration', type: FieldType.number } as Column; + const mockColumnFilters = { + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + duration: { columnId: 'duration', columnDef: mockColumnDuration, searchTerms: ['5..22'], operator: 'RangeInclusive' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + }); + }); + + describe('updateFilters method with OData version 4', () => { + beforeEach(() => { + serviceOptions.version = 4; + const columns = [{ id: 'company', field: 'company' }, { id: 'gender', field: 'gender' }, { id: 'name', field: 'name' }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + }); + + it('should return a query with a date showing as Date as per OData 4 requirement', () => { + const expectation = `$top=10&$filter=(contains(Company, 'abc') and UpdatedDate eq 2001-02-28T00:00:00Z)`; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnUpdated = { id: 'updatedDate', field: 'updatedDate', type: FieldType.date } as Column; + const mockColumnFilters = { + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + updatedDate: { columnId: 'updatedDate', columnDef: mockColumnUpdated, searchTerms: ['2001-02-28'], operator: 'EQ' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query to filter a search value between an inclusive range of numbers using the 2 dots (..) separator and the "RangeInclusive" operator', () => { + const expectation = `$top=10&$filter=(contains(Company, 'abc') and (Duration ge 5 and Duration le 22))`; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnDuration = { id: 'duration', field: 'duration', type: FieldType.number } as Column; + const mockColumnFilters = { + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + duration: { columnId: 'duration', columnDef: mockColumnDuration, searchTerms: ['5..22'], operator: 'RangeInclusive' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query to filter a search value between an exclusive range of numbers using 2 search terms and the "RangeExclusive" operator', () => { + const expectation = `$top=10&$filter=(contains(Company, 'abc') and (Duration gt 5 and Duration lt 22))`; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnDuration = { id: 'duration', field: 'duration', type: FieldType.number } as Column; + const mockColumnFilters = { + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + duration: { columnId: 'duration', columnDef: mockColumnDuration, searchTerms: [5, 22], operator: 'RangeExclusive' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query to filter a search value between an exclusive range of numbers using 2 search terms and the "RangeExclusive" operator and type is default (string)', () => { + const expectation = `$top=10&$filter=(contains(Company, 'abc') and (Duration gt 5 and Duration lt 22))`; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnDuration = { id: 'duration', field: 'duration' } as Column; + const mockColumnFilters = { + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + duration: { columnId: 'duration', columnDef: mockColumnDuration, searchTerms: [5, 22], operator: 'RangeExclusive' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query to filter a search value between an inclusive range of dates using the 2 dots (..) separator and the "RangeInclusive" operator', () => { + const expectation = `$top=10&$filter=(contains(Company, 'abc') and (UpdatedDate ge 2001-01-20T00:00:00Z and UpdatedDate le 2001-02-28T00:00:00Z))`; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnUpdated = { id: 'updatedDate', field: 'updatedDate', type: FieldType.date } as Column; + const mockColumnFilters = { + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + updatedDate: { columnId: 'updatedDate', columnDef: mockColumnUpdated, searchTerms: ['2001-01-20..2001-02-28'], operator: 'RangeInclusive' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query to filter a search value between an exclusive range of dates using 2 search terms and the "RangeExclusive" operator', () => { + const expectation = `$top=10&$filter=(contains(Company, 'abc') and (UpdatedDate gt 2001-01-20T00:00:00Z and UpdatedDate lt 2001-02-28T00:00:00Z))`; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnUpdated = { id: 'updatedDate', field: 'updatedDate', type: FieldType.date } as Column; + const mockColumnFilters = { + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + updatedDate: { columnId: 'updatedDate', columnDef: mockColumnUpdated, searchTerms: ['2001-01-20', '2001-02-28'], operator: 'RangeExclusive' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with a date equal when only 1 searchTerms is provided and even if the operator is set to a range', () => { + const expectation = `$top=10&$filter=(contains(Company, 'abc') and UpdatedDate eq 2001-01-20T00:00:00Z)`; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnUpdated = { id: 'updatedDate', field: 'updatedDate', type: FieldType.date } as Column; + const mockColumnFilters = { + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + updatedDate: { columnId: 'updatedDate', columnDef: mockColumnUpdated, searchTerms: ['2001-01-20'], operator: 'RangeExclusive' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query without any date filtering when searchTerms is an empty array', () => { + const expectation = `$top=10&$filter=(contains(Company, 'abc'))`; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnUpdated = { id: 'updatedDate', field: 'updatedDate', type: FieldType.date } as Column; + const mockColumnFilters = { + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + updatedDate: { columnId: 'updatedDate', columnDef: mockColumnUpdated, searchTerms: [], operator: 'RangeExclusive' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query without any number filtering when searchTerms is an empty array', () => { + const expectation = `$top=10&$filter=(contains(Company, 'abc'))`; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnDuration = { id: 'duration', field: 'duration', type: FieldType.number } as Column; + const mockColumnFilters = { + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + duration: { columnId: 'duration', columnDef: mockColumnDuration, searchTerms: [], operator: 'RangeInclusive' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + describe('set "enablePagination" to False', () => { + beforeEach(() => { + gridOptionMock.enablePagination = false; + }); + + it('should return a query with a date showing as Date as per OData 4 requirement but without pagination when "enablePagination" is set to False', () => { + const expectation = `$filter=(contains(Company, 'abc') and UpdatedDate eq 2001-02-28T00:00:00Z)`; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnUpdated = { id: 'updatedDate', field: 'updatedDate', type: FieldType.date } as Column; + const mockColumnFilters = { + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + updatedDate: { columnId: 'updatedDate', columnDef: mockColumnUpdated, searchTerms: ['2001-02-28'], operator: 'EQ' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query to filter a search value between an inclusive range of numbers using the 2 dots (..) separator and the "RangeInclusive" operator but without pagination when "enablePagination" is set to False', () => { + const expectation = `$filter=(contains(Company, 'abc') and (Duration ge 5 and Duration le 22))`; + const mockColumnCompany = { id: 'company', field: 'company' } as Column; + const mockColumnDuration = { id: 'duration', field: 'duration', type: FieldType.number } as Column; + const mockColumnFilters = { + company: { columnId: 'company', columnDef: mockColumnCompany, searchTerms: ['abc'], operator: 'Contains' }, + duration: { columnId: 'duration', columnDef: mockColumnDuration, searchTerms: ['5..22'], operator: 'RangeInclusive' }, + } as ColumnFilters; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(mockColumnFilters, false); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + }); + }); + + describe('updateSorters method', () => { + beforeEach(() => { + const columns = [{ id: 'company', field: 'company' }, { id: 'gender', field: 'gender' }, { id: 'name', field: 'name' }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + }); + + it('should return a query with the multiple new sorting when using multiColumnSort', () => { + const expectation = `$top=10&$orderby=Gender desc,FirstName asc`; + const mockColumnSort = [ + { columnId: 'gender', sortCol: { id: 'gender', field: 'gender' }, sortAsc: false }, + { columnId: 'firstName', sortCol: { id: 'firstName', field: 'firstName' }, sortAsc: true } + ] as ColumnSort[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateSorters(mockColumnSort); + const query = service.buildQuery(); + const currentSorters = service.getCurrentSorters(); + + expect(query).toBe(expectation); + expect(currentSorters).toEqual([{ columnId: 'Gender', direction: 'desc' }, { columnId: 'FirstName', direction: 'asc' }]); + }); + + it('should return a query string using a different field to query when the column has a "queryField" defined in its definition', () => { + const expectation = `$top=10&$orderby=Gender desc,FirstName asc`; + const mockColumnSort = [ + { columnId: 'gender', sortCol: { id: 'gender', field: 'gender' }, sortAsc: false }, + { columnId: 'name', sortCol: { id: 'name', field: 'name', queryField: 'firstName' }, sortAsc: true } + ] as ColumnSort[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateSorters(mockColumnSort); + const query = service.buildQuery(); + const currentSorters = service.getCurrentSorters(); + + expect(query).toBe(expectation); + expect(currentSorters).toEqual([{ columnId: 'Gender', direction: 'desc' }, { columnId: 'Name', direction: 'asc' }]); + }); + + it('should return a query string using a different field to query when the column has a "queryFieldSorter" defined in its definition', () => { + const expectation = `$top=10&$orderby=Gender desc,LastName asc`; + const mockColumnSort = [ + { columnId: 'gender', sortCol: { id: 'gender', field: 'gender' }, sortAsc: false }, + { columnId: 'name', sortCol: { id: 'name', field: 'name', queryField: 'isAfter', queryFieldSorter: 'lastName' }, sortAsc: true } + ] as ColumnSort[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateSorters(mockColumnSort); + const query = service.buildQuery(); + const currentSorters = service.getCurrentSorters(); + + expect(query).toBe(expectation); + expect(currentSorters).toEqual([{ columnId: 'Gender', direction: 'desc' }, { columnId: 'Name', direction: 'asc' }]); + }); + + it('should return a query without the field sorter when its field property is missing', () => { + const expectation = `$top=10&$orderby=Gender desc`; + const mockColumnSort = [ + { columnId: 'gender', sortCol: { id: 'gender', field: 'gender' }, sortAsc: false }, + { columnId: 'firstName', sortCol: { id: 'firstName' }, sortAsc: true } + ] as ColumnSort[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateSorters(mockColumnSort); + const query = service.buildQuery(); + const currentSorters = service.getCurrentSorters(); + + expect(query).toBe(expectation); + expect(currentSorters).toEqual([{ columnId: 'Gender', direction: 'desc' }, { columnId: 'FirstName', direction: 'asc' }]); + }); + + it('should return a query without any sorting after clearSorters was called', () => { + const expectation = `$top=10`; + const mockColumnSort = [ + { columnId: 'gender', sortCol: { id: 'gender', field: 'gender' }, sortAsc: false }, + { columnId: 'firstName', sortCol: { id: 'firstName', field: 'firstName' }, sortAsc: true } + ] as ColumnSort[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateSorters(mockColumnSort); + service.clearSorters(); + const query = service.buildQuery(); + const currentSorters = service.getCurrentSorters(); + + expect(query).toBe(expectation); + expect(currentSorters).toEqual([]); + }); + + it('should return a query with the multiple new sorting when "updateSorters" with currentSorter defined as preset on 2nd argument', () => { + const expectation = `$top=10&$orderby=Gender asc,FirstName desc`; + const mockCurrentSorter = [ + { columnId: 'gender', direction: 'asc' }, + { columnId: 'firstName', direction: 'DESC' } + ] as CurrentSorter[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateSorters(null, mockCurrentSorter); + const query = service.buildQuery(); + const currentSorters = service.getCurrentSorters(); + + expect(query).toBe(expectation); + expect(currentSorters).toEqual([{ columnId: 'gender', direction: 'asc' }, { columnId: 'firstName', direction: 'desc' }]); + }); + + describe('set "enablePagination" to False', () => { + beforeEach(() => { + gridOptionMock.enablePagination = false; + }); + + it('should return a query without the field sorter when its field property is missing but without pagination when "enablePagination" is set to False', () => { + const expectation = `$orderby=Gender desc`; + const mockColumnSort = [ + { columnId: 'gender', sortCol: { id: 'gender', field: 'gender' }, sortAsc: false }, + { columnId: 'firstName', sortCol: { id: 'firstName' }, sortAsc: true } + ] as ColumnSort[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateSorters(mockColumnSort); + const query = service.buildQuery(); + const currentSorters = service.getCurrentSorters(); + + expect(query).toBe(expectation); + expect(currentSorters).toEqual([{ columnId: 'Gender', direction: 'desc' }, { columnId: 'FirstName', direction: 'asc' }]); + }); + + it('should return a query without any sorting after clearSorters was called but without pagination when "enablePagination" is set to False', () => { + const expectation = ''; + const mockColumnSort = [ + { columnId: 'gender', sortCol: { id: 'gender', field: 'gender' }, sortAsc: false }, + { columnId: 'firstName', sortCol: { id: 'firstName', field: 'firstName' }, sortAsc: true } + ] as ColumnSort[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateSorters(mockColumnSort); + service.clearSorters(); + const query = service.buildQuery(); + const currentSorters = service.getCurrentSorters(); + + expect(query).toBe(expectation); + expect(currentSorters).toEqual([]); + }); + }); + }); + + describe('presets', () => { + it('should return a query when using presets sorters array', () => { + const expectation = `$top=10&$orderby=Company desc,FirstName asc`; + const presets = [ + { columnId: 'company', direction: 'DESC' }, + { columnId: 'firstName', direction: 'ASC' }, + ] as CurrentSorter[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateSorters(undefined, presets); + const query = service.buildQuery(); + const currentSorters = service.getCurrentSorters(); + + expect(query).toBe(expectation); + expect(currentSorters).toEqual(presets); + }); + + it('should return a query with a filter with range of numbers when the preset is a filter range with 2 dots (..) separator', () => { + const columns = [{ id: 'company', field: 'company' }, { id: 'gender', field: 'gender' }, { id: 'duration', field: 'duration', type: FieldType.number }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + const expectation = `$top=10&$filter=(Duration gt 4 and Duration lt 88)`; + const presetFilters = [ + { columnId: 'duration', searchTerms: ['4..88'] }, + ] as CurrentFilter[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(presetFilters, true); + const query = service.buildQuery(); + const currentFilters = service.getCurrentFilters(); + + expect(query).toBe(expectation); + expect(currentFilters).toEqual(presetFilters); + }); + + it('should return a query with a filter with range of numbers with decimals when the preset is a filter range with 3 dots (...) separator', () => { + const columns = [{ id: 'company', field: 'company' }, { id: 'gender', field: 'gender' }, { id: 'duration', field: 'duration', type: FieldType.number }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + const expectation = `$top=10&$filter=(Duration gt 0.5 and Duration lt .88)`; + const presetFilters = [ + { columnId: 'duration', searchTerms: ['0.5...88'] }, + ] as CurrentFilter[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(presetFilters, true); + const query = service.buildQuery(); + const currentFilters = service.getCurrentFilters(); + + expect(query).toBe(expectation); + expect(currentFilters).toEqual(presetFilters); + }); + + it('should return a query with a filter with range of numbers when the preset is a filter range with 2 searchTerms', () => { + const columns = [{ id: 'company', field: 'company' }, { id: 'gender', field: 'gender' }, { id: 'duration', field: 'duration', type: FieldType.number }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + const expectation = `$top=10&$filter=(Duration ge 4 and Duration le 88)`; + const presetFilters = [ + { columnId: 'duration', searchTerms: [4, 88], operator: 'RangeInclusive' }, + ] as CurrentFilter[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(presetFilters, true); + const query = service.buildQuery(); + const currentFilters = service.getCurrentFilters(); + + expect(query).toBe(expectation); + expect(currentFilters).toEqual(presetFilters); + }); + + it('should return a query with a filter with range of dates when the preset is a filter range with 2 dots (..) separator', () => { + const columns = [{ id: 'company', field: 'company' }, { id: 'gender', field: 'gender' }, { id: 'finish', field: 'finish', type: FieldType.date }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + const expectation = `$top=10&$filter=(Finish gt DateTime'2001-01-01T00:00:00Z' and Finish lt DateTime'2001-01-31T00:00:00Z')`; + const presetFilters = [ + { columnId: 'finish', searchTerms: ['2001-01-01..2001-01-31'] }, + ] as CurrentFilter[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(presetFilters, true); + const query = service.buildQuery(); + const currentFilters = service.getCurrentFilters(); + + expect(query).toBe(expectation); + expect(currentFilters).toEqual(presetFilters); + }); + + it('should return a query with a filter with range of dates when the preset is a filter range with 2 searchTerms', () => { + const columns = [{ id: 'company', field: 'company' }, { id: 'gender', field: 'gender' }, { id: 'finish', field: 'finish', type: FieldType.date }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + const expectation = `$top=10&$filter=(Finish ge DateTime'2001-01-01T00:00:00Z' and Finish le DateTime'2001-01-31T00:00:00Z')`; + const presetFilters = [ + { columnId: 'finish', searchTerms: ['2001-01-01', '2001-01-31'], operator: 'RangeInclusive' }, + ] as CurrentFilter[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(presetFilters, true); + const query = service.buildQuery(); + const currentFilters = service.getCurrentFilters(); + + expect(query).toBe(expectation); + expect(currentFilters).toEqual(presetFilters); + }); + + it('should return a query with a filter with range of dates inclusive when the preset is a filter range with 2 searchTerms without an operator', () => { + const columns = [{ id: 'company', field: 'company' }, { id: 'gender', field: 'gender' }, { id: 'finish', field: 'finish', type: FieldType.date }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + const expectation = `$top=10&$filter=(Finish gt DateTime'2001-01-01T00:00:00Z' and Finish lt DateTime'2001-01-31T00:00:00Z')`; + const presetFilters = [ + { columnId: 'finish', searchTerms: ['2001-01-01', '2001-01-31'] }, + ] as CurrentFilter[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(presetFilters, true); + const query = service.buildQuery(); + const currentFilters = service.getCurrentFilters(); + + expect(query).toBe(expectation); + expect(currentFilters).toEqual(presetFilters); + }); + + describe('set "enablePagination" to False', () => { + beforeEach(() => { + gridOptionMock.enablePagination = false; + }); + + it('should return a query when using presets sorters array but without pagination when "enablePagination" is set to False', () => { + const expectation = `$orderby=Company desc,FirstName asc`; + const presets = [ + { columnId: 'company', direction: 'DESC' }, + { columnId: 'firstName', direction: 'ASC' }, + ] as CurrentSorter[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateSorters(undefined, presets); + const query = service.buildQuery(); + const currentSorters = service.getCurrentSorters(); + + expect(query).toBe(expectation); + expect(currentSorters).toEqual(presets); + }); + + it('should return a query with a filter with range of numbers when the preset is a filter range with 2 dots (..) separator but without pagination when "enablePagination" is set to False', () => { + const columns = [{ id: 'company', field: 'company' }, { id: 'gender', field: 'gender' }, { id: 'duration', field: 'duration', type: FieldType.number }]; + jest.spyOn(gridStub, 'getColumns').mockReturnValue(columns); + const expectation = `$filter=(Duration gt 4 and Duration lt 88)`; + const presetFilters = [ + { columnId: 'duration', searchTerms: ['4..88'] }, + ] as CurrentFilter[]; + + service.init(serviceOptions, paginationOptions, gridStub); + service.updateFilters(presetFilters, true); + const query = service.buildQuery(); + const currentFilters = service.getCurrentFilters(); + + expect(query).toBe(expectation); + expect(currentFilters).toEqual(presetFilters); + }); + }); + }); + + describe('mapOdataOperator method', () => { + it('should return lower than OData operator', () => { + const output = service.mapOdataOperator('<'); + expect(output).toBe('lt'); + }); + + it('should return lower than OData operator', () => { + const output = service.mapOdataOperator('<='); + expect(output).toBe('le'); + }); + + it('should return lower than OData operator', () => { + const output = service.mapOdataOperator('>'); + expect(output).toBe('gt'); + }); + + it('should return lower than OData operator', () => { + const output = service.mapOdataOperator('>='); + expect(output).toBe('ge'); + }); + + it('should return lower than OData operator', () => { + const output1 = service.mapOdataOperator('<>'); + const output2 = service.mapOdataOperator('!='); + + expect(output1).toBe('ne'); + expect(output2).toBe('ne'); + }); + + it('should return lower than OData operator', () => { + const output1 = service.mapOdataOperator('='); + const output2 = service.mapOdataOperator('=='); + const output3 = service.mapOdataOperator(''); + + expect(output1).toBe('eq'); + expect(output2).toBe('eq'); + expect(output3).toBe('eq'); + }); + }); +}); diff --git a/packages/odata/src/services/__tests__/odataQueryBuilder.service.spec.ts b/packages/odata/src/services/__tests__/odataQueryBuilder.service.spec.ts new file mode 100644 index 000000000..594dacb3c --- /dev/null +++ b/packages/odata/src/services/__tests__/odataQueryBuilder.service.spec.ts @@ -0,0 +1,218 @@ +import { CaseType } from '@slickgrid-universal/common'; +import { OdataQueryBuilderService } from '../odataQueryBuilder.service'; + +describe('OdataService', () => { + let service: OdataQueryBuilderService; + + beforeEach(() => { + service = new OdataQueryBuilderService(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create the service', () => { + expect(service).toBeTruthy(); + }); + + describe('buildQuery method', () => { + beforeEach(() => { + service.options = { filterQueue: [], orderBy: '' }; + }); + + it('should throw an error when odata options are null', () => { + service.options = undefined; + expect(() => service.buildQuery()).toThrow(); + }); + + it('should return a query with $top pagination', () => { + const expectation = '$top=25'; + + service.options = { top: 25 }; + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with $top and $skip pagination', () => { + const expectation = '$top=25&$skip=10'; + + service.options = { top: 25, skip: 10 }; + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with sorting when "orderBy" is provided as a string', () => { + const expectation = '$top=10&$orderby=Gender asc'; + + service.options = { top: 10, orderBy: 'Gender asc' }; + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with sorting when "orderBy" is provided as an array', () => { + const expectation = '$top=10&$orderby=Gender asc,Company desc'; + + service.options = { top: 10, orderBy: ['Gender asc', 'Company desc'] }; + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with filters when "filter" is provided as a string', () => { + const expectation = `$top=10&$filter=(FirstName eq 'John')`; + + service.options = { top: 10, filter: `FirstName eq 'John'` }; + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with filters when "filterBy" is provided as a string', () => { + const expectation = `$top=10&$filter=(FirstName eq 'John')`; + + service.options = { top: 10, filterBy: `FirstName eq 'John'` }; + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with filters when "filterBy" is provided as an array', () => { + const expectation = `$top=10&$filter=(FirstName eq 'John' and FirstName eq 'Jane')`; + + service.options = { top: 10, filter: [`FirstName eq 'John'`, `FirstName eq 'Jane'`] }; + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with filters when "filterBy" is provided as an array', () => { + const expectation = `$top=10&$filter=(FirstName eq 'John' or FirstName eq 'Jane')`; + + service.options = { top: 10, filterBy: [`FirstName eq 'John'`, `FirstName eq 'Jane'`], filterBySeparator: 'or' }; + const query = service.buildQuery(); + const filterCount = service.getFilterCount(); + + expect(query).toBe(expectation); + expect(filterCount).toBe(2); + }); + }); + + describe('saveColumnFilter method', () => { + it('should return "columnFilters" object with multiple properties as the filter names', () => { + service.saveColumnFilter('FirstName', 'John', ['John']); + + const columnFilters = service.columnFilters; + + expect(columnFilters).toEqual({ FirstName: { search: ['John'], value: 'John' } }); + }); + + it('should return "columnFilters" object with multiple properties as the filter names', () => { + service.saveColumnFilter('FirstName', 'John', ['John']); + service.saveColumnFilter('LastName', 'Doe', ['Doe']); + + const columnFilters = service.columnFilters; + + expect(columnFilters).toEqual({ + FirstName: { search: ['John'], value: 'John' }, + LastName: { search: ['Doe'], value: 'Doe' } + }); + }); + }); + + describe('removeColumnFilter method', () => { + it('should return "columnFilters" object without the one deleted', () => { + service.saveColumnFilter('FirstName', 'John', ['John']); + service.saveColumnFilter('LastName', 'Doe', ['Doe']); + + service.removeColumnFilter('LastName'); + const columnFilters = service.columnFilters; + + expect(columnFilters).toEqual({ + FirstName: { search: ['John'], value: 'John' } + }); + }); + }); + + describe('updateOptions method', () => { + beforeEach(() => { + service.updateOptions({ caseType: CaseType.pascalCase }); + }); + + it('should be able to provide "filterBy" array and expect see the query string include the filter', () => { + const expectation = `$filter=(FirstName eq 'John')`; + + service.updateOptions({ filterBy: `FirstName eq 'John'` }); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should be able to provide "filterBy" array and expect see the query string include the filter', () => { + const expectation = `$filter=(FirstName eq 'John' and substring(Gender 'male')`; + + service.updateOptions({ filterBy: [`FirstName eq 'John'`, `substring(Gender 'male'`] }); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should be able to provide "orderBy" array and expect see the query string include the filter', () => { + const expectation = `$orderby=FirstName desc`; + + service.updateOptions({ orderBy: 'FirstName desc' }); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should be able to provide "orderBy" array and expect see the query string include the filter', () => { + const expectation = `$orderby=FirstName desc,LastName asc`; + + service.updateOptions({ orderBy: ['FirstName desc', 'LastName asc'] }); + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + }); + + describe('enableCount flag', () => { + it('should return a query with "$inlinecount" when "enableCount" is set and no OData version is provided', () => { + const expectation = `$inlinecount=allpages&$top=10&$filter=(FirstName eq 'John')`; + + service.options = { top: 10, filterBy: `FirstName eq 'John'`, enableCount: true }; + const query = service.buildQuery(); + + expect(query).toBe(expectation); + }); + + it('should return a query with "$inlinecount" when "enableCount" is set with OData version 2 or 3', () => { + const expectation = `$inlinecount=allpages&$top=10&$filter=(FirstName eq 'John')`; + + service.options = { top: 10, filterBy: `FirstName eq 'John'`, enableCount: true, version: 2 }; + const query1 = service.buildQuery(); + + service.options = { top: 10, filterBy: `FirstName eq 'John'`, enableCount: true, version: 3 }; + const query2 = service.buildQuery(); + + expect(query1).toBe(expectation); + expect(query2).toBe(expectation); + }); + + it('should return a query with "$count" when "enableCount" is set with OData version 4 or higher', () => { + const expectation = `$count=true&$top=10&$filter=(FirstName eq 'John')`; + + service.options = { top: 10, filterBy: `FirstName eq 'John'`, enableCount: true, version: 4 }; + const query1 = service.buildQuery(); + + service.options = { top: 10, filterBy: `FirstName eq 'John'`, enableCount: true, version: 5 }; + const query2 = service.buildQuery(); + + expect(query1).toBe(expectation); + expect(query2).toBe(expectation); + }); + }); +}); diff --git a/packages/odata/src/services/grid-odata.service.ts b/packages/odata/src/services/grid-odata.service.ts new file mode 100644 index 000000000..30bbff7a9 --- /dev/null +++ b/packages/odata/src/services/grid-odata.service.ts @@ -0,0 +1,613 @@ +import { + // utilities + parseUtcDate, + mapOperatorByFieldType, + titleCase, + + // enums/interfaces + BackendService, + CaseType, + Column, + ColumnFilter, + ColumnFilters, + ColumnSort, + CurrentFilter, + CurrentPagination, + CurrentSorter, + FilterChangedArgs, + FieldType, + GridOption, + MultiColumnSort, + Pagination, + PaginationChangedArgs, + SortDirection, + SortDirectionString, + OperatorType, + OperatorString, + SearchTerm +} from '@slickgrid-universal/common'; +import { OdataQueryBuilderService } from './odataQueryBuilder.service'; +import { OdataOption, OdataSortingOption } from '../interfaces/index'; + +const DEFAULT_ITEMS_PER_PAGE = 25; +const DEFAULT_PAGE_SIZE = 20; + +export class GridOdataService implements BackendService { + private _currentFilters: CurrentFilter[] = []; + private _currentPagination: CurrentPagination | null; + private _currentSorters: CurrentSorter[] = []; + private _columnDefinitions: Column[]; + private _grid: any; + private _odataService: OdataQueryBuilderService; + options: Partial; + pagination: Pagination | undefined; + defaultOptions: OdataOption = { + top: DEFAULT_ITEMS_PER_PAGE, + orderBy: '', + caseType: CaseType.pascalCase + }; + + /** Getter for the Column Definitions */ + get columnDefinitions() { + return this._columnDefinitions; + } + + /** Getter for the Odata Service */ + get odataService() { + return this._odataService; + } + + /** Getter for the Grid Options pulled through the Grid Object */ + private get _gridOptions(): GridOption { + return (this._grid && this._grid.getOptions) ? this._grid.getOptions() : {}; + } + + constructor() { + this._odataService = new OdataQueryBuilderService(); + } + + init(serviceOptions: Partial, pagination?: Pagination, grid?: any): void { + this._grid = grid; + const mergedOptions = { ...this.defaultOptions, ...serviceOptions }; + + // unless user specifically set "enablePagination" to False, we'll add "top" property for the pagination in every other cases + if (this._gridOptions && !this._gridOptions.enablePagination) { + // save current pagination as Page 1 and page size as "top" + this._odataService.options = { ...mergedOptions, top: undefined }; + this._currentPagination = null; + } else { + const topOption = (pagination && pagination.pageSize) ? pagination.pageSize : this.defaultOptions.top; + this._odataService.options = { ...mergedOptions, top: topOption }; + this._currentPagination = { + pageNumber: 1, + pageSize: this._odataService.options.top || this.defaultOptions.top || DEFAULT_PAGE_SIZE + }; + } + + this.options = this._odataService.options; + this.pagination = pagination; + + if (grid?.getColumns) { + this._columnDefinitions = grid.getColumns() || []; + this._columnDefinitions = this._columnDefinitions.filter((column: Column) => !column.excludeFromQuery); + } + } + + buildQuery(): string { + return this._odataService.buildQuery(); + } + + clearFilters() { + this._currentFilters = []; + this.updateFilters([]); + } + + clearSorters() { + this._currentSorters = []; + this.updateSorters([]); + } + + updateOptions(serviceOptions?: Partial) { + this.options = { ...this.options, ...serviceOptions }; + this._odataService.options = this.options; + } + + removeColumnFilter(fieldName: string): void { + this._odataService.removeColumnFilter(fieldName); + } + + /** Get the Filters that are currently used by the grid */ + getCurrentFilters(): CurrentFilter[] { + return this._currentFilters; + } + + /** Get the Pagination that is currently used by the grid */ + getCurrentPagination(): CurrentPagination | null { + return this._currentPagination; + } + + /** Get the Sorters that are currently used by the grid */ + getCurrentSorters(): CurrentSorter[] { + return this._currentSorters; + } + + /** + * Mapper for mathematical operators (ex.: <= is "le", > is "gt") + * @param string operator + * @returns string map + */ + mapOdataOperator(operator: string) { + let map = ''; + switch (operator) { + case '<': + map = 'lt'; + break; + case '<=': + map = 'le'; + break; + case '>': + map = 'gt'; + break; + case '>=': + map = 'ge'; + break; + case '<>': + case '!=': + map = 'ne'; + break; + case '=': + case '==': + default: + map = 'eq'; + break; + } + + return map; + } + + /* + * Reset the pagination options + */ + resetPaginationOptions() { + this._odataService.updateOptions({ + skip: 0 + }); + } + + saveColumnFilter(fieldName: string, value: string, terms?: any[]) { + this._odataService.saveColumnFilter(fieldName, value, terms); + } + + /* + * FILTERING + */ + processOnFilterChanged(event: Event, args: FilterChangedArgs): string { + const gridOptions: GridOption = this._gridOptions; + const backendApi = gridOptions.backendServiceApi; + + if (backendApi === undefined) { + throw new Error('Something went wrong in the GridOdataService, "backendServiceApi" is not initialized'); + } + + // keep current filters & always save it as an array (columnFilters can be an object when it is dealt by SlickGrid Filter) + this._currentFilters = this.castFilterToColumnFilters(args.columnFilters); + + if (!args || !args.grid) { + throw new Error('Something went wrong when trying create the GridOdataService, it seems that "args" is not populated correctly'); + } + + // loop through all columns to inspect filters & set the query + this.updateFilters(args.columnFilters); + + this.resetPaginationOptions(); + return this._odataService.buildQuery(); + } + + /* + * PAGINATION + */ + processOnPaginationChanged(event: Event, args: PaginationChangedArgs) { + const pageSize = +(args.pageSize || ((this.pagination) ? this.pagination.pageSize : DEFAULT_PAGE_SIZE)); + this.updatePagination(args.newPage, pageSize); + + // build the OData query which we will use in the WebAPI callback + return this._odataService.buildQuery(); + } + + /* + * SORTING + */ + processOnSortChanged(event: Event, args: ColumnSort | MultiColumnSort) { + const sortColumns = (args.multiColumnSort) ? (args as MultiColumnSort).sortCols : new Array({ sortCol: (args as ColumnSort).sortCol, sortAsc: (args as ColumnSort).sortAsc }); + + // loop through all columns to inspect sorters & set the query + this.updateSorters(sortColumns); + + // build the OData query which we will use in the WebAPI callback + return this._odataService.buildQuery(); + } + + /** + * loop through all columns to inspect filters & update backend service filters + * @param columnFilters + */ + updateFilters(columnFilters: ColumnFilters | CurrentFilter[], isUpdatedByPresetOrDynamically?: boolean) { + let searchBy = ''; + const searchByArray: string[] = []; + const odataVersion = this._odataService && this._odataService.options && this._odataService.options.version || 2; + + // on filter preset load, we need to keep current filters + if (isUpdatedByPresetOrDynamically) { + this._currentFilters = this.castFilterToColumnFilters(columnFilters); + } + + // loop through all columns to inspect filters + for (const columnId in columnFilters) { + if (columnFilters.hasOwnProperty(columnId)) { + const columnFilter = columnFilters[columnId]; + + // if user defined some "presets", then we need to find the filters from the column definitions instead + let columnDef: Column | undefined; + if (isUpdatedByPresetOrDynamically && Array.isArray(this._columnDefinitions)) { + columnDef = this._columnDefinitions.find((column: Column) => { + return column.id === columnFilter.columnId; + }); + } else { + columnDef = columnFilter.columnDef; + } + if (!columnDef) { + throw new Error('[GridOData Service]: Something went wrong in trying to get the column definition of the specified filter (or preset filters). Did you make a typo on the filter columnId?'); + } + + let fieldName = columnDef.queryFieldFilter || columnDef.queryField || columnDef.field || columnDef.name || ''; + const fieldType = columnDef.type || FieldType.string; + let searchTerms = (columnFilter ? columnFilter.searchTerms : null) || []; + let fieldSearchValue = (Array.isArray(searchTerms) && searchTerms.length === 1) ? searchTerms[0] : ''; + if (typeof fieldSearchValue === 'undefined') { + fieldSearchValue = ''; + } + + if (!fieldName) { + throw new Error(`GridOData filter could not find the field name to query the search, your column definition must include a valid "field" or "name" (optionally you can also use the "queryfield" or "queryFieldFilter").`); + } + + fieldSearchValue = '' + fieldSearchValue; // make sure it's a string + const matches = fieldSearchValue.match(/^([<>!=\*]{0,2})(.*[^<>!=\*])([\*]?)$/); // group 1: Operator, 2: searchValue, 3: last char is '*' (meaning starts with, ex.: abc*) + let operator = columnFilter.operator || ((matches) ? matches[1] : ''); + let searchValue = (!!matches) ? matches[2] : ''; + const lastValueChar = (!!matches) ? matches[3] : (operator === '*z' ? '*' : ''); + const bypassOdataQuery = columnFilter.bypassBackendQuery || false; + + // no need to query if search value is empty + if (fieldName && searchValue === '' && searchTerms.length <= 1) { + this.removeColumnFilter(fieldName); + continue; + } + + if (Array.isArray(searchTerms) && searchTerms.length === 1 && typeof searchTerms[0] === 'string' && searchTerms[0].indexOf('..') > 0) { + searchTerms = searchTerms[0].split('..'); + if (!operator) { + operator = OperatorType.rangeExclusive; + } + } + + // escaping the search value + searchValue = searchValue.replace(`'`, `''`); // escape single quotes by doubling them + searchValue = encodeURIComponent(searchValue); // encode URI of the final search value + + // 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) { + operator = columnDef.filter.operator; + } + + // if we still don't have an operator find the proper Operator to use by it's field type + if (!operator) { + operator = mapOperatorByFieldType(columnDef.type || FieldType.string); + } + + // extra query arguments + if (bypassOdataQuery) { + // push to our temp array and also trim white spaces + if (fieldName) { + this.saveColumnFilter(fieldName, fieldSearchValue, searchTerms); + } + } else { + searchBy = ''; + + // titleCase the fieldName so that it matches the WebApi names + if (this._odataService.options.caseType === CaseType.pascalCase) { + fieldName = titleCase(fieldName || ''); + } + + if (fieldType === FieldType.date) { + searchBy = this.filterBySearchDate(fieldName, operator, searchTerms, odataVersion); + } else if (searchTerms && searchTerms.length > 1 && (operator === 'IN' || operator === 'NIN' || operator === 'NOTIN' || operator === 'NOT IN' || operator === 'NOT_IN')) { + // when having more than 1 search term (then check if we have a "IN" or "NOT IN" filter search) + const tmpSearchTerms = []; + + if (operator === 'IN') { + // example:: (Stage eq "Expired" or Stage eq "Renewal") + for (let j = 0, lnj = searchTerms.length; j < lnj; j++) { + if (fieldType === FieldType.string) { + const searchVal = encodeURIComponent(searchTerms[j].replace(`'`, `''`)); + tmpSearchTerms.push(`${fieldName} eq '${searchVal}'`); + } else { + // Single quote escape is not needed for non string type + tmpSearchTerms.push(`${fieldName} eq ${searchTerms[j]}`); + } + } + searchBy = tmpSearchTerms.join(' or '); + if (!(typeof searchBy === 'string' && searchBy[0] === '(' && searchBy.slice(-1) === ')')) { + searchBy = `(${searchBy})`; + } + } else { + // example:: (Stage ne "Expired" and Stage ne "Renewal") + for (let k = 0, lnk = searchTerms.length; k < lnk; k++) { + const searchVal = encodeURIComponent(searchTerms[k].replace(`'`, `''`)); + tmpSearchTerms.push(`${fieldName} ne '${searchVal}'`); + } + searchBy = tmpSearchTerms.join(' and '); + if (!(typeof searchBy === 'string' && searchBy[0] === '(' && searchBy.slice(-1) === ')')) { + searchBy = `(${searchBy})`; + } + } + } else if (operator === '*' || operator === 'a*' || operator === '*z' || lastValueChar === '*') { + // first/last character is a '*' will be a startsWith or endsWith + searchBy = (operator === '*' || operator === '*z') ? `endswith(${fieldName}, '${searchValue}')` : `startswith(${fieldName}, '${searchValue}')`; + } else if (fieldType === FieldType.string) { + // string field needs to be in single quotes + if (operator === '' || operator === OperatorType.contains || operator === OperatorType.notContains) { + searchBy = this.odataQueryVersionWrapper('substring', odataVersion, fieldName, searchValue); + if (operator === OperatorType.notContains) { + searchBy = `not ${searchBy}`; + } + } else if (operator === OperatorType.rangeExclusive || operator === OperatorType.rangeInclusive) { + // example:: (Duration >= 5 and Duration <= 10) + searchBy = this.filterBySearchTermRange(fieldName, operator, searchTerms); + } else { + searchBy = `${fieldName} ${this.mapOdataOperator(operator)} '${searchValue}'`; + } + } else { + if (operator === OperatorType.rangeExclusive || operator === OperatorType.rangeInclusive) { + // example:: (Duration >= 5 and Duration <= 10) + searchBy = this.filterBySearchTermRange(fieldName, operator, searchTerms); + } else { + // any other field type (or undefined type) + searchValue = (fieldType === FieldType.number || fieldType === FieldType.boolean) ? searchValue : `'${searchValue}'`; + searchBy = `${fieldName} ${this.mapOdataOperator(operator)} ${searchValue}`; + } + } + + // push to our temp array and also trim white spaces + if (searchBy !== '') { + searchByArray.push(searchBy.trim()); + this.saveColumnFilter(fieldName || '', fieldSearchValue, searchValue); + } + } + } + } + + // update the service options with filters for the buildQuery() to work later + this._odataService.updateOptions({ + filter: (searchByArray.length > 0) ? searchByArray.join(' and ') : '', + skip: undefined + }); + } + + /** + * Update the pagination component with it's new page number and size + * @param newPage + * @param pageSize + */ + updatePagination(newPage: number, pageSize: number) { + this._currentPagination = { + pageNumber: newPage, + pageSize + }; + + // unless user specifically set "enablePagination" to False, we'll update pagination options in every other cases + if (this._gridOptions && (this._gridOptions.enablePagination || !this._gridOptions.hasOwnProperty('enablePagination'))) { + this._odataService.updateOptions({ + top: pageSize, + skip: (newPage - 1) * pageSize + }); + } + } + + /** + * loop through all columns to inspect sorters & update backend service orderBy + * @param columnFilters + */ + updateSorters(sortColumns?: ColumnSort[], presetSorters?: CurrentSorter[]) { + let currentSorters: CurrentSorter[] = []; + const odataSorters: OdataSortingOption[] = []; + + if (!sortColumns && presetSorters) { + // make the presets the current sorters, also make sure that all direction are in lowercase for OData + currentSorters = presetSorters; + currentSorters.forEach((sorter) => sorter.direction = sorter.direction.toLowerCase() as SortDirectionString); + + // display the correct sorting icons on the UI, for that it requires (columnId, sortAsc) properties + const tmpSorterArray = currentSorters.map((sorter) => { + const columnDef = this._columnDefinitions.find((column: Column) => column.id === sorter.columnId); + + odataSorters.push({ + field: columnDef ? ((columnDef.queryFieldSorter || columnDef.queryField || columnDef.field) + '') : (sorter.columnId + ''), + direction: sorter.direction + }); + + // return only the column(s) found in the Column Definitions ELSE null + if (columnDef) { + return { + columnId: sorter.columnId, + sortAsc: sorter.direction.toUpperCase() === SortDirection.ASC + }; + } + return null; + }); + + // set the sort icons, but also make sure to filter out null values (that happens when columnDef is not found) + if (Array.isArray(tmpSorterArray)) { + this._grid.setSortColumns(tmpSorterArray); + } + } else if (sortColumns && !presetSorters) { + // build the SortBy string, it could be multisort, example: customerNo asc, purchaserName desc + if (sortColumns && sortColumns.length === 0) { + // TODO fix this line + // currentSorters = new Array(this.defaultOptions.orderBy); // when empty, use the default sort + } else { + if (sortColumns) { + for (const columnDef of sortColumns) { + if (columnDef.sortCol) { + let fieldName = (columnDef.sortCol.queryFieldSorter || columnDef.sortCol.queryField || columnDef.sortCol.field) + ''; + let columnFieldName = (columnDef.sortCol.field || columnDef.sortCol.id) + ''; + let queryField = (columnDef.sortCol.queryFieldSorter || columnDef.sortCol.queryField || columnDef.sortCol.field || '') + ''; + if (this._odataService.options.caseType === CaseType.pascalCase) { + fieldName = titleCase(fieldName); + columnFieldName = titleCase(columnFieldName); + queryField = titleCase(queryField); + } + + if (columnFieldName !== '') { + currentSorters.push({ + columnId: columnFieldName, + direction: columnDef.sortAsc ? 'asc' : 'desc' + }); + } + + if (queryField !== '') { + odataSorters.push({ + field: queryField, + direction: columnDef.sortAsc ? SortDirection.ASC : SortDirection.DESC + }); + } + } + } + } + } + } + + // transform the sortby array into a CSV string for OData + currentSorters = currentSorters || [] as CurrentSorter[]; + const csvString = odataSorters.map((sorter) => { + let str = ''; + if (sorter && sorter.field) { + const sortField = (this._odataService.options.caseType === CaseType.pascalCase) ? titleCase(sorter.field) : sorter.field; + str = `${sortField} ${sorter && sorter.direction && sorter.direction.toLowerCase() || ''}`; + } + return str; + }).join(','); + + this._odataService.updateOptions({ + orderBy: csvString + }); + + // keep current Sorters and update the service options with the new sorting + this._currentSorters = currentSorters; + + // build the OData query which we will use in the WebAPI callback + return this._odataService.buildQuery(); + } + + // + // private functions + // ------------------- + /** + * Cast provided filters (could be in multiple format) into an array of ColumnFilter + * @param columnFilters + */ + private castFilterToColumnFilters(columnFilters: ColumnFilters | CurrentFilter[]): CurrentFilter[] { + // keep current filters & always save it as an array (columnFilters can be an object when it is dealt by SlickGrid Filter) + const filtersArray: ColumnFilter[] = (typeof columnFilters === 'object') ? Object.keys(columnFilters).map(key => columnFilters[key]) : columnFilters; + + if (!Array.isArray(filtersArray)) { + return []; + } + + return filtersArray.map((filter) => { + const tmpFilter: CurrentFilter = { columnId: filter.columnId || '' }; + if (filter.operator) { + tmpFilter.operator = filter.operator; + } + if (Array.isArray(filter.searchTerms)) { + tmpFilter.searchTerms = filter.searchTerms; + } + return tmpFilter; + }); + } + + private odataQueryVersionWrapper(queryType: 'dateTime' | 'substring', version: number, fieldName: string, searchValue = ''): string { + let query = ''; + switch (queryType) { + case 'dateTime': + query = version >= 4 ? searchValue : `DateTime'${searchValue}'`; + break; + case 'substring': + query = version >= 4 ? `contains(${fieldName}, '${searchValue}')` : `substringof('${searchValue}', ${fieldName})`; + break; + } + return query; + } + + /** + * Filter by a search date, the searchTerms might be a single value or range of dates (2 searchTerms OR 1 string separated by 2 dots "date1..date2") + * Also depending on the OData version number, the output will be different, previous version must wrap dates with DateTime + * - version 2-3:: Finish gt DateTime'2019-08-12T00:00:00Z' + * - version 4:: Finish gt 2019-08-12T00:00:00Z + */ + private filterBySearchDate(fieldName: string, operator: OperatorType | OperatorString, searchTerms: SearchTerm[], version: number): string { + let query = ''; + let searchValues: SearchTerm[] = []; + if (Array.isArray(searchTerms) && searchTerms.length > 1) { + searchValues = searchTerms; + if (operator !== OperatorType.rangeExclusive && operator !== OperatorType.rangeInclusive && this._gridOptions.defaultFilterRangeOperator) { + operator = this._gridOptions.defaultFilterRangeOperator; + } + } + + // single search value + if (searchValues.length === 0 && Array.isArray(searchTerms) && searchTerms.length === 1 && searchTerms[0]) { + const searchValue1 = this.odataQueryVersionWrapper('dateTime', version, fieldName, parseUtcDate(searchTerms[0] as string, true)); + if (searchValue1) { + return `${fieldName} ${this.mapOdataOperator(operator)} ${searchValue1}`; + } + } + + // multiple search value (date range) + if (Array.isArray(searchValues) && searchValues.length === 2 && searchValues[0] && searchValues[1]) { + // date field needs to be UTC and within DateTime function + const searchValue1 = this.odataQueryVersionWrapper('dateTime', version, fieldName, parseUtcDate(searchValues[0] as string, true)); + const searchValue2 = this.odataQueryVersionWrapper('dateTime', version, fieldName, parseUtcDate(searchValues[1] as string, true)); + + if (searchValue1 && searchValue2) { + if (operator === OperatorType.rangeInclusive) { + // example:: (Finish >= DateTime'2019-08-11T00:00:00Z' and Finish <= DateTime'2019-09-12T00:00:00Z') + query = `(${fieldName} ge ${searchValue1} and ${fieldName} le ${searchValue2})`; + } else if (operator === OperatorType.rangeExclusive) { + // example:: (Finish > DateTime'2019-08-11T00:00:00Z' and Finish < DateTime'2019-09-12T00:00:00Z') + query = `(${fieldName} gt ${searchValue1} and ${fieldName} lt ${searchValue2})`; + } + } + } + return query; + } + + /** + * Filter by a range of searchTerms (2 searchTerms OR 1 string separated by 2 dots "value1..value2") + */ + private filterBySearchTermRange(fieldName: string, operator: OperatorType | OperatorString, searchTerms: SearchTerm[]) { + let query = ''; + + if (Array.isArray(searchTerms) && searchTerms.length === 2) { + if (operator === OperatorType.rangeInclusive) { + // example:: (Duration >= 5 and Duration <= 10) + query = `(${fieldName} ge ${searchTerms[0]} and ${fieldName} le ${searchTerms[1]})`; + } else if (operator === OperatorType.rangeExclusive) { + // example:: (Duration > 5 and Duration < 10) + query = `(${fieldName} gt ${searchTerms[0]} and ${fieldName} lt ${searchTerms[1]})`; + } + } + return query; + } +} diff --git a/packages/odata/src/services/index.ts b/packages/odata/src/services/index.ts new file mode 100644 index 000000000..cf919ef96 --- /dev/null +++ b/packages/odata/src/services/index.ts @@ -0,0 +1,2 @@ +export * from './grid-odata.service'; +export * from './odataQueryBuilder.service'; diff --git a/packages/odata/src/services/odataQueryBuilder.service.ts b/packages/odata/src/services/odataQueryBuilder.service.ts new file mode 100644 index 000000000..48ba656b8 --- /dev/null +++ b/packages/odata/src/services/odataQueryBuilder.service.ts @@ -0,0 +1,150 @@ +import { CaseType, titleCase } from '@slickgrid-universal/common'; +import { OdataOption } from '../interfaces/odataOption.interface'; + +export class OdataQueryBuilderService { + _columnFilters: any; + _defaultSortBy: string; + _filterCount: number; + _odataOptions: Partial; + + constructor() { + this._odataOptions = { + filterQueue: [], + orderBy: '' + }; + this._defaultSortBy = ''; + this._columnFilters = {}; + } + + /* + * Build the OData query string from all the options provided + * @return string OData query + */ + buildQuery(): string { + if (!this._odataOptions) { + throw new Error('Odata Service requires certain options like "top" for it to work'); + } + this._odataOptions.filterQueue = []; + const queryTmpArray = []; + + // When enableCount is set, add it to the OData query + if (this._odataOptions && this._odataOptions.enableCount === true) { + const countQuery = (this._odataOptions.version && this._odataOptions.version >= 4) ? '$count=true' : '$inlinecount=allpages'; + queryTmpArray.push(countQuery); + } + + if (this._odataOptions.top) { + queryTmpArray.push(`$top=${this._odataOptions.top}`); + } + if (this._odataOptions.skip) { + queryTmpArray.push(`$skip=${this._odataOptions.skip}`); + } + if (this._odataOptions.orderBy) { + let argument = ''; + if (Array.isArray(this._odataOptions.orderBy)) { + argument = this._odataOptions.orderBy.join(','); // csv, that will form a query, for example: $orderby=RoleName asc, Id desc + } else { + argument = this._odataOptions.orderBy; + } + queryTmpArray.push(`$orderby=${argument}`); + } + if (this._odataOptions.filterBy || this._odataOptions.filter) { + const filterBy = this._odataOptions.filter || this._odataOptions.filterBy; + if (filterBy) { + this._filterCount = 1; + this._odataOptions.filterQueue = []; + let filterStr = filterBy; + if (Array.isArray(filterBy)) { + this._filterCount = filterBy.length; + filterStr = filterBy.join(` ${this._odataOptions.filterBySeparator || 'and'} `); + } + + if (typeof filterStr === 'string') { + if (!(filterStr[0] === '(' && filterStr.slice(-1) === ')')) { + this.addToFilterQueueWhenNotExists(`(${filterStr})`); + } else { + this.addToFilterQueueWhenNotExists(filterStr); + } + } + } + } + if (this._odataOptions.filterQueue.length > 0) { + const query = this._odataOptions.filterQueue.join(` ${this._odataOptions.filterBySeparator || 'and'} `); + this._odataOptions.filter = query; // overwrite with + queryTmpArray.push(`$filter=${query}`); + } + + // join all the odata functions by a '&' + return queryTmpArray.join('&'); + } + + getFilterCount(): number { + return this._filterCount; + } + + get columnFilters(): any[] { + return this._columnFilters; + } + + get options(): Partial { + return this._odataOptions; + } + + set options(options: Partial) { + this._odataOptions = options; + } + + removeColumnFilter(fieldName: string) { + if (this._columnFilters && this._columnFilters.hasOwnProperty(fieldName)) { + delete this._columnFilters[fieldName]; + } + } + + saveColumnFilter(fieldName: string, value: any, searchTerms?: any[]) { + this._columnFilters[fieldName] = { + search: searchTerms, + value + }; + } + + /** + * Change any OData options that will be used to build the query + * @param object options + */ + updateOptions(options: Partial) { + for (const property of Object.keys(options)) { + if (options.hasOwnProperty(property)) { + this._odataOptions[property] = options[property]; // replace of the property + } + + // we need to keep the defaultSortBy for references whenever the user removes his Sorting + // then we would revert to the defaultSortBy and the only way is to keep a hard copy here + if (property === 'orderBy' || property === 'sortBy') { + let sortBy = options[property]; + + // make sure first char of each orderBy field is capitalize + if (this._odataOptions.caseType === CaseType.pascalCase) { + if (Array.isArray(sortBy)) { + sortBy.forEach((field, index, inputArray) => { + inputArray[index] = titleCase(field); + }); + } else { + sortBy = titleCase(options[property]); + } + } + this._odataOptions.orderBy = sortBy; + this._defaultSortBy = sortBy; + } + } + } + + // + // private functions + // ------------------- + + private addToFilterQueueWhenNotExists(filterStr: string) { + if (this._odataOptions.filterQueue && this._odataOptions.filterQueue.indexOf(filterStr) === -1) { + this._odataOptions.filterQueue.push(filterStr); + } + } +} diff --git a/packages/odata/tsconfig.build.json b/packages/odata/tsconfig.build.json new file mode 100644 index 000000000..407988bc5 --- /dev/null +++ b/packages/odata/tsconfig.build.json @@ -0,0 +1,40 @@ +{ + "compilerOptions": { + "module": "es2020", + "moduleResolution": "node", + "target": "es2015", + "lib": [ + "es2020", + "dom" + ], + "typeRoots": [ + "../typings", + "../../node_modules/@types" + ], + "outDir": "dist/amd", + "noImplicitAny": true, + "suppressImplicitAnyIndexErrors": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "skipLibCheck": true, + "strictNullChecks": true, + "declaration": true, + "forceConsistentCasingInFileNames": true, + "experimentalDecorators": true, + "noEmitHelpers": false, + "stripInternal": true, + "sourceMap": true + }, + "exclude": [ + ".vscode", + "src/examples", + "src/resources", + "test", + "**/*.spec.ts" + ], + "include": [ + "../typings", + "**/*" + ] +} diff --git a/packages/odata/tsconfig.json b/packages/odata/tsconfig.json new file mode 100644 index 000000000..41e6266de --- /dev/null +++ b/packages/odata/tsconfig.json @@ -0,0 +1,41 @@ +{ + "extends": "../tsconfig-build.json", + "compileOnSave": false, + "compilerOptions": { + "rootDir": "src", + "declarationDir": "dist/commonjs", + "outDir": "dist/commonjs", + "target": "es2015", + "module": "esnext", + "sourceMap": true, + "allowSyntheticDefaultImports": true, + "noImplicitReturns": true, + "lib": [ + "es2020", + "dom" + ], + "types": [ + "moment", + "node" + ], + "typeRoots": [ + "node_modules/@types", + "src/typings" + ] + }, + "exclude": [ + "cypress", + "dist", + "node_modules", + "**/*.spec.ts" + ], + "filesGlob": [ + "./src/**/*.ts", + "./test/**/*.ts", + "./custom_typings/**/*.d.ts" + ], + "include": [ + "src/**/*.ts", + "src/typings/**/*.ts" + ] +} From a96ec387da08860685112b84098bfe559de5f7ec Mon Sep 17 00:00:00 2001 From: Ghislain Beaulac Date: Fri, 5 Jun 2020 09:48:55 -0400 Subject: [PATCH 02/66] (readme): add todo step to look into --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ca969cc28..3d4162227 100644 --- a/README.md +++ b/README.md @@ -154,4 +154,5 @@ npm run test:watch - [x] Add possibility to use SVG instead of Font Family - [x] Add Typings for Grid & DataView objects - [ ] Cannot copy text from cell since it's not selectable -- [ ] Remove all Services init method 2nd argument (we can get DataView from the Grid object) +- [ ] Remove all Services init method 2nd argument (we can get DataView directly from the Grid object) +- [ ] Add build (bundle) step in CircleCI build From fc9cf6bb98b34c8d35e22c12aa36d7b2bd4563fc Mon Sep 17 00:00:00 2001 From: Ghislain Beaulac Date: Fri, 12 Jun 2020 19:36:33 -0400 Subject: [PATCH 03/66] refactor(backend): start adding OData/GraphQL Examples --- packages/common/src/services/index.ts | 3 +- packages/graphql/src/index.ts | 1 + packages/odata/src/index.ts | 1 + .../vanilla-bundle/src/vanilla-grid-bundle.ts | 142 +++++++- .../src/app-routing.ts | 2 + packages/web-demo-vanilla-bundle/src/app.html | 6 + .../examples/data/collection_100_numbers.json | 12 + .../examples/data/collection_500_numbers.json | 52 +++ .../src/examples/data/countries.json | 245 ++++++++++++++ .../src/examples/data/country_names.json | 245 ++++++++++++++ .../src/examples/data/customers_100.json | 1 + .../src/examples/data/customers_100_ASC.json | 1 + .../src/examples/data/customers_100_DESC.json | 1 + .../src/examples/example09.html | 30 ++ .../src/examples/example09.ts | 307 ++++++++++++++++++ .../src/examples/example10.html | 47 +++ .../src/examples/example10.ts | 270 +++++++++++++++ .../web-demo-vanilla-bundle/src/renderer.ts | 3 + 18 files changed, 1351 insertions(+), 18 deletions(-) create mode 100644 packages/web-demo-vanilla-bundle/src/examples/data/collection_100_numbers.json create mode 100644 packages/web-demo-vanilla-bundle/src/examples/data/collection_500_numbers.json create mode 100644 packages/web-demo-vanilla-bundle/src/examples/data/countries.json create mode 100644 packages/web-demo-vanilla-bundle/src/examples/data/country_names.json create mode 100644 packages/web-demo-vanilla-bundle/src/examples/data/customers_100.json create mode 100644 packages/web-demo-vanilla-bundle/src/examples/data/customers_100_ASC.json create mode 100644 packages/web-demo-vanilla-bundle/src/examples/data/customers_100_DESC.json create mode 100644 packages/web-demo-vanilla-bundle/src/examples/example09.html create mode 100644 packages/web-demo-vanilla-bundle/src/examples/example09.ts create mode 100644 packages/web-demo-vanilla-bundle/src/examples/example10.html create mode 100644 packages/web-demo-vanilla-bundle/src/examples/example10.ts diff --git a/packages/common/src/services/index.ts b/packages/common/src/services/index.ts index de2f6a276..a0f10f947 100644 --- a/packages/common/src/services/index.ts +++ b/packages/common/src/services/index.ts @@ -1,8 +1,9 @@ +export * from './backend-utilities'; export * from './collection.service'; export * from './excelExport.service'; export * from './export-utilities'; -export * from './fileExport.service'; export * from './extension.service'; +export * from './fileExport.service'; export * from './filter.service'; export * from './grid.service'; export * from './gridEvent.service'; diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index 5181074f8..0e31cc612 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -1,2 +1,3 @@ export { GraphqlService } from './services/graphql.service'; export { default as GraphqlQueryBuilder } from './services/graphqlQueryBuilder'; +export * from './interfaces/index'; diff --git a/packages/odata/src/index.ts b/packages/odata/src/index.ts index 0622a2033..17f5f4eed 100644 --- a/packages/odata/src/index.ts +++ b/packages/odata/src/index.ts @@ -1,2 +1,3 @@ export { GridOdataService } from './services/grid-odata.service'; export { OdataQueryBuilderService } from './services/odataQueryBuilder.service'; +export * from './interfaces/index'; diff --git a/packages/vanilla-bundle/src/vanilla-grid-bundle.ts b/packages/vanilla-bundle/src/vanilla-grid-bundle.ts index 52e6c883b..cddf531c3 100644 --- a/packages/vanilla-bundle/src/vanilla-grid-bundle.ts +++ b/packages/vanilla-bundle/src/vanilla-grid-bundle.ts @@ -51,9 +51,13 @@ import { SortService, SlickgridConfig, TreeDataService, + executeBackendProcessesCallback, + onBackendError, + // refreshBackendDataset, convertParentChildArrayToHierarchicalView, GetSlickEventType, + BackendServiceOption, } from '@slickgrid-universal/common'; import { FileExportService } from './services/fileExport.service'; @@ -85,6 +89,7 @@ export class VanillaGridBundle { grid: SlickGrid; metrics: Metrics; customDataView = false; + paginationOptions: any; groupItemMetadataProvider: SlickGroupItemMetadataProvider; resizerPlugin: SlickResizer; @@ -278,6 +283,9 @@ export class VanillaGridBundle { this.sharedService.internalPubSubService = this._eventPubSubService; this._eventHandler = new Slick.EventHandler(); const dataviewInlineFilters = this._gridOptions?.dataView?.inlineFilters ?? false; + + this.createBackendApiInternalPostProcessCallback(this._gridOptions); + if (!this.customDataView) { if (this._gridOptions.draggableGrouping || this._gridOptions.enableGrouping) { this.extensionUtility.loadExtensionDynamically(ExtensionName.groupItemMetaProvider); @@ -424,6 +432,12 @@ export class VanillaGridBundle { registeringServices.push(this.treeDataService); } + // bind the Backend Service API callback functions only after the grid is initialized + // because the preProcess() and onInit() might get triggered + if (this.gridOptions && this.gridOptions.backendServiceApi) { + this.bindBackendCallbackFunctions(this.gridOptions); + } + // bind & initialize all Services that were tagged as enable // register all services by executing their init method and providing them with the Grid object if (Array.isArray(registeringServices)) { @@ -442,7 +456,11 @@ export class VanillaGridBundle { dataView: this.dataView, slickGrid: this.grid, + // public methods + dispose: this.dispose.bind(this), + // return all available Services (non-singleton) + backendService: this.gridOptions && this.gridOptions.backendServiceApi && this.gridOptions.backendServiceApi.service, filterService: this.filterService, gridEventService: this.gridEventService, gridStateService: this.gridStateService, @@ -489,6 +507,31 @@ export class VanillaGridBundle { return options; } + /** + * Define our internal Post Process callback, it will execute internally after we get back result from the Process backend call + * For now, this is GraphQL Service ONLY feature and it will basically + * refresh the Dataset & Pagination without having the user to create his own PostProcess every time + */ + createBackendApiInternalPostProcessCallback(gridOptions: GridOption) { + const backendApi = gridOptions && gridOptions.backendServiceApi; + if (backendApi && backendApi.service) { + const backendApiService = backendApi.service; + + // internalPostProcess only works (for now) with a GraphQL Service, so make sure it is of that type + if (/* backendApiService instanceof GraphqlService || */ typeof backendApiService.getDatasetName === 'function') { + backendApi.internalPostProcess = (processResult: any) => { + this._dataset = []; + const datasetName = (backendApi && backendApiService && typeof backendApiService.getDatasetName === 'function') ? backendApiService.getDatasetName() : ''; + if (processResult && processResult.data && processResult.data[datasetName]) { + this._dataset = processResult.data[datasetName].hasOwnProperty('nodes') ? (processResult as any).data[datasetName].nodes : (processResult as any).data[datasetName]; + const totalCount = processResult.data[datasetName].hasOwnProperty('totalCount') ? (processResult as any).data[datasetName].totalCount : (processResult as any).data[datasetName].length; + this.refreshGridData(this._dataset, totalCount || 0); + } + }; + } + } + } + bindDifferentHooks(grid: SlickGrid, gridOptions: GridOption, dataView: SlickDataView) { // bind external filter (backend) when available or default onFilter (dataView) if (gridOptions.enableFiltering && !this.customDataView) { @@ -516,6 +559,15 @@ export class VanillaGridBundle { } } + // if user set an onInit Backend, we'll run it right away (and if so, we also need to run preProcess, internalPostProcess & postProcess) + if (gridOptions.backendServiceApi) { + const backendApi = gridOptions.backendServiceApi; + + if (backendApi && backendApi.service && backendApi.service.init) { + backendApi.service.init(backendApi.options, gridOptions.pagination, this.grid); + } + } + if (dataView && grid) { // expose all Slick Grid Events through dispatch for (const prop in grid) { @@ -576,6 +628,62 @@ export class VanillaGridBundle { } } + bindBackendCallbackFunctions(gridOptions: GridOption) { + const backendApi = gridOptions.backendServiceApi; + const backendApiService = backendApi && backendApi.service; + const serviceOptions: BackendServiceOption = backendApiService && backendApiService.options || {}; + const isExecuteCommandOnInit = (!serviceOptions) ? false : ((serviceOptions && serviceOptions.hasOwnProperty('executeProcessCommandOnInit')) ? serviceOptions['executeProcessCommandOnInit'] : true); + + if (backendApiService) { + // update backend filters (if need be) BEFORE the query runs (via the onInit command a few lines below) + // if user entered some any "presets", we need to reflect them all in the grid + if (gridOptions && gridOptions.presets) { + // Filters "presets" + if (backendApiService.updateFilters && Array.isArray(gridOptions.presets.filters) && gridOptions.presets.filters.length > 0) { + backendApiService.updateFilters(gridOptions.presets.filters, true); + } + // Sorters "presets" + if (backendApiService.updateSorters && Array.isArray(gridOptions.presets.sorters) && gridOptions.presets.sorters.length > 0) { + backendApiService.updateSorters(undefined, gridOptions.presets.sorters); + } + // Pagination "presets" + if (backendApiService.updatePagination && gridOptions.presets.pagination) { + const { pageNumber, pageSize } = gridOptions.presets.pagination; + backendApiService.updatePagination(pageNumber, pageSize); + } + } else { + const columnFilters = this.filterService.getColumnFilters(); + if (columnFilters && backendApiService.updateFilters) { + backendApiService.updateFilters(columnFilters, false); + } + } + + // execute onInit command when necessary + if (backendApi && backendApiService && (backendApi.onInit || isExecuteCommandOnInit)) { + const query = (typeof backendApiService.buildQuery === 'function') ? backendApiService.buildQuery() : ''; + const process = (isExecuteCommandOnInit) ? (backendApi.process && backendApi.process(query) || null) : (backendApi.onInit && backendApi.onInit(query) || null); + + // wrap this inside a setTimeout to avoid timing issue since the gridOptions needs to be ready before running this onInit + setTimeout(() => { + // keep start time & end timestamps & return it after process execution + const startTime = new Date(); + + // run any pre-process, if defined, for example a spinner + if (backendApi.preProcess) { + backendApi.preProcess(); + } + + // the processes can be a Promise (like Http) + if (process instanceof Promise && process.then) { + const totalItems = this.gridOptions && this.gridOptions.pagination && this.gridOptions.pagination.totalItems || 0; + process.then((processResult: any) => executeBackendProcessesCallback(startTime, processResult, backendApi, totalItems)) + .catch((error) => onBackendError(error, backendApi)); + } + }); + } + } + } + /** * When dataset changes, we need to refresh the entire grid UI & possibly resize it as well * @param dataset @@ -614,23 +722,23 @@ export class VanillaGridBundle { // display the Pagination component only after calling this refresh data first, we call it here so that if we preset pagination page number it will be shown correctly // this.showPagination = (this._gridOptions && (this._gridOptions.enablePagination || (this._gridOptions.backendServiceApi && this._gridOptions.enablePagination === undefined))) ? true : false; - // if (this._gridOptions && this._gridOptions.backendServiceApi && this._gridOptions.pagination && this.paginationOptions) { - // const paginationOptions = this.setPaginationOptionsWhenPresetDefined(this._gridOptions, this.paginationOptions); - - // // when we have a totalCount use it, else we'll take it from the pagination object - // // only update the total items if it's different to avoid refreshing the UI - // const totalRecords = (totalCount !== undefined) ? totalCount : (this._gridOptions && this._gridOptions.pagination && this._gridOptions.pagination.totalItems); - // if (totalRecords !== undefined && totalRecords !== this.totalItems) { - // this.totalItems = +totalRecords; - // } - // // initialize the Pagination Service with new pagination options (which might have presets) - // if (!this._isPaginationInitialized) { - // this.initializePaginationService(paginationOptions); - // } else { - // // update the pagination service with the new total - // this.paginationService.totalItems = this.totalItems; - // } - // } + if (this._gridOptions && this._gridOptions.backendServiceApi && this._gridOptions.pagination && this.paginationOptions) { + // const paginationOptions = this.setPaginationOptionsWhenPresetDefined(this._gridOptions, this.paginationOptions); + + // // when we have a totalCount use it, else we'll take it from the pagination object + // // only update the total items if it's different to avoid refreshing the UI + // const totalRecords = (totalCount !== undefined) ? totalCount : (this._gridOptions && this._gridOptions.pagination && this._gridOptions.pagination.totalItems); + // if (totalRecords !== undefined && totalRecords !== this.totalItems) { + // this.totalItems = +totalRecords; + // } + // // initialize the Pagination Service with new pagination options (which might have presets) + // if (!this._isPaginationInitialized) { + // this.initializePaginationService(paginationOptions); + // } else { + // // update the pagination service with the new total + // this.paginationService.totalItems = this.totalItems; + // } + } // resize the grid inside a slight timeout, in case other DOM element changed prior to the resize (like a filter/pagination changed) if (this.grid && this._gridOptions.enableAutoResize) { diff --git a/packages/web-demo-vanilla-bundle/src/app-routing.ts b/packages/web-demo-vanilla-bundle/src/app-routing.ts index 899cf5199..efc1d7efd 100644 --- a/packages/web-demo-vanilla-bundle/src/app-routing.ts +++ b/packages/web-demo-vanilla-bundle/src/app-routing.ts @@ -12,6 +12,8 @@ export class AppRouting { { route: 'example06', name: 'example06', title: 'Example06', moduleId: './examples/example06' }, { route: 'example07', name: 'example07', title: 'Example07', moduleId: './examples/example07' }, { route: 'example08', name: 'example08', title: 'Example08', moduleId: './examples/example08' }, + { route: 'example09', name: 'example09', title: 'Example09', moduleId: './examples/example09' }, + { route: 'example10', name: 'example10', title: 'Example10', moduleId: './examples/example10' }, { route: 'example50', name: 'example50', title: 'Example50', moduleId: './examples/example50' }, { route: 'example51', name: 'example51', title: 'Example51', moduleId: './examples/example51' }, { route: '', redirect: 'example01' }, diff --git a/packages/web-demo-vanilla-bundle/src/app.html b/packages/web-demo-vanilla-bundle/src/app.html index 4a1831d41..dcd96a1bf 100644 --- a/packages/web-demo-vanilla-bundle/src/app.html +++ b/packages/web-demo-vanilla-bundle/src/app.html @@ -51,6 +51,12 @@

Slickgrid-Universal

Example08 - Column Span & Header Grouping + + Example09 - Grid with Backend OData Service + + + Example10 - Grid with Backend GraphQL Service +