diff --git a/.circleci/config.yml b/.circleci/config.yml index b035af6b0..4239a06ec 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,16 +9,29 @@ jobs: steps: - checkout - restore_cache: - key: slickgrid-universal-build-{{ .Branch }}-{{ checksum "package.json" }} + key: slickgrid-universal-build-{{ .Branch }}-{{ checksum "yarn.lock" }} - run: yarn install - save_cache: - key: slickgrid-universal-build-{{ .Branch }}-{{ checksum "package.json" }} + key: slickgrid-universal-build-{{ .Branch }}-{{ checksum "yarn.lock" }} paths: - "node_modules" - run: name: TypeScript Build (tsc) + command: yarn run build + # - run: + # name: Build Full Bundle (all Bundler types) + # command: yarn run bundle + # - run: + # name: Website Prod Build + # command: yarn run build:demo + - run: + name: Run Web Server + command: npm run serve + background: true + - run: + name: Running Cypress E2E tests with JUnit XML reporter command: | - yarn run build + yarn run cypress:ci - run: name: Run Jest tests with JUnit as reporter command: ./node_modules/.bin/jest --config test/jest.config.js --ci --runInBand --collectCoverage=true --reporters=default --reporters=jest-junit @@ -27,3 +40,22 @@ jobs: - run: name: Upload Jest coverage to Codecov command: bash <(curl -s https://codecov.io/bash) + - run: + name: Build Full Bundle (all Bundler types) + command: yarn run bundle + # - run: + # name: Website Prod Build + # command: yarn run build:demo + # - run: + # name: Run Web Server + # command: npm run dev:watch + # background: true + # - run: + # name: Run Web Server + # command: npm run serve + # background: true + # - run: + # name: Running Cypress E2E tests with JUnit XML reporter + # command: | + # yarn run cypress:ci + diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 000000000..20592d3f8 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,15 @@ +coverage: + precision: 2 + round: up + range: "50...95" + + status: + project: + default: + target: auto + # Fail the status if coverage drops by >= 3% + threshold: 3% + branches: null + patch: + default: + threshold: 5% diff --git a/.eslintrc b/.eslintrc index 12d2407b4..546887922 100644 --- a/.eslintrc +++ b/.eslintrc @@ -155,8 +155,16 @@ "space-in-parens": [ "error" ], - "spaced-comment": "error", + "spaced-comment": [ + "error", + "always", + { + "markers": [ + "/" + ] + } + ], "use-isnan": "error", "valid-typeof": "off" } -} +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4a0e92f5d..0cbc91d99 100644 --- a/.gitignore +++ b/.gitignore @@ -36,7 +36,6 @@ npm-debug.log testem.log /typings yarn-error.log -yarn.lock # System Files .DS_Store @@ -56,3 +55,4 @@ dist-demo **/junit.xml **/test-report.xml **/testresult.xml +**/test-results.xml diff --git a/.npmignore b/.npmignore index 5cce14ebc..2cc262831 100644 --- a/.npmignore +++ b/.npmignore @@ -32,6 +32,7 @@ **/junit.xml **/test-report.xml **/testresult.xml +**/test-results.xml # compressed distribution files dist-grid-bundle-zip diff --git a/README.md b/README.md index 47b98b6be..868c00063 100644 --- a/README.md +++ b/README.md @@ -7,32 +7,32 @@ [![codecov](https://codecov.io/gh/ghiscoding/slickgrid-universal/branch/master/graph/badge.svg)](https://codecov.io/gh/ghiscoding/slickgrid-universal) [![jest](https://jestjs.io/img/jest-badge.svg)](https://github.com/facebook/jest) -This is a monorepo project (using Lerna) which is regrouping a few packages under a single repository. -The goal is to create a common repo that includes all Editors, Filters, Extensions and Services -that could be used by any Framework (it is framework agnostic). -It's also a good opportunity to decouple some features/services that not every project require at all time, -this will also help in getting smaller bundle size depending on which features (packages) are used. For example, not every project requires backend services (OData, GraphQL), +This is a monorepo project (using Lerna) which is regrouping a few packages under a single repository. +The goal is to create a common repo that includes all Editors, Filters, Extensions and Services +that could be used by any Framework (it is framework agnostic). +It's also a good opportunity to decouple some features/services that not every project require at all time, +this will also help in getting smaller bundle size depending on which features (packages) are used. For example, not every project requires backend services (OData, GraphQL), which is why they are better handled with a monorepo structure. ### Demo page -The GitHub [demo page](https://ghiscoding.github.io/slickgrid-universal) uses 2 different themes (Material Design / Salesforce) but you could also use Bootstrap theme which is demoed in other frameworks. +The GitHub [demo page](https://ghiscoding.github.io/slickgrid-universal) uses 2 different themes (Material Design / Salesforce) but you could also use Bootstrap theme which is demoed in other frameworks. - [Web-Demo-Vanilla-Bundle](https://ghiscoding.github.io/slickgrid-universal) with Material Design theme & Salesforce theme - [Angular-Slickgrid](https://ghiscoding.github.io/Angular-Slickgrid/) -- [Slickgrid-Universal](https://ghiscoding.github.io/aurelia-slickgrid/) +- [Aurelia-Slickgrid](https://ghiscoding.github.io/aurelia-slickgrid/) ### Why create this monorepo? You might be wondering why was this monorepo created? Here are a few of the reasons: -1. it removes a lot of duplicate code that exist in both -[Angular-Slickgrid](https://github.com/ghiscoding/Angular-Slickgrid) and [Slickgrid-Universal](https://github.com/ghiscoding/aurelia-slickgrid) +1. it removes a lot of duplicate code that exist in both +[Angular-Slickgrid](https://github.com/ghiscoding/Angular-Slickgrid) and [Aurelia-Slickgrid](https://github.com/ghiscoding/aurelia-slickgrid) (these libs have over 80% of code in common and that is not very DRY). 2. decouple some Services that should not be required at all time (OData, GraphQL, Export to File, Export to Excel, ...) 3. framework agnostic, it could be implemented in many more frameworks in the future (interested in adding other frameworks? please contact me...) ### Frameworks using this monorepo -This is a Work in Progress, the goal is to eventually to rewrite [Angular-Slickgrid](https://github.com/ghiscoding/Angular-Slickgrid) -and [Slickgrid-Universal](https://github.com/ghiscoding/aurelia-slickgrid) to use this monorepo which will simplify debugging/fixing common code. +This is a Work in Progress, the goal is to eventually to rewrite [Angular-Slickgrid](https://github.com/ghiscoding/Angular-Slickgrid) +and [Aurelia-Slickgrid](https://github.com/ghiscoding/aurelia-slickgrid) to use this monorepo which will simplify debugging/fixing common code. -Note however that this project also has a Vanilla Implementation (not associated to any framework) +Note however that this project also has a Vanilla Implementation (not associated to any framework) and it is also used to test with the UI portion. The Vanilla bundle is also used in our SalesForce (with Lightning Web Component) hence the creation of this monorepo. #### The main packages structure is the following @@ -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** @@ -66,8 +66,8 @@ npm run build 3. Run Dev (Vanilla Implementation) -There is a Vanilla flavour implementation of this monorepo, vanilla means that it is not associated to any framework -and is written in plain TypeScript without being bound to any framework. The implementation is very similar to Angular and Aurelia. +There is a Vanilla flavour implementation of this monorepo, vanilla means that it is not associated to any framework +and is written in plain TypeScript without being bound to any framework. The implementation is very similar to Angular and Aurelia. It could be used as a guideline to implement it in with other frameworks. ```bash @@ -88,7 +88,7 @@ npm run test:watch - [x] Aggregators (6) - [x] Editors (11) - [x] Filters (17) - - [ ] Add optional debounce filter delay to local grid + - [ ] Add optional debounce filter delay on local grid - [x] Formatters (31) - [ ] Extensions - [x] AutoTooltip @@ -112,22 +112,25 @@ 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 - [x] Grouping & Col Span - [x] Pagination - - [ ] Resizer + - [ ] Resizer - moved the Service to an Extension - [x] Shared - [x] Sort - [ ] Others / Vanilla Implementation - [x] Custom Footer + - [ ] add unit tests (possibly rewrite component in vanilla JS) + - [x] Backend Services + Pagination + - [x] Local Pagination + - [x] Grid Presets - [ ] Dynamically Add Columns - - [ ] Grid Presets - - [ ] Local Pagination + - [ ] Translations Support - [ ] Tree Data - [x] add Grid Demo - [x] add Collapse/Expand All into Context Menu @@ -142,17 +145,18 @@ npm run test:watch - [x] Add Multiple Example Demos with Vanilla implementation - [x] Add GitHub Demo website - [x] Add CI/CD (CircleCI or GitHub Actions) - - [x] Add Jest Unit tests - [ ] Add Cypress E2E tests - - [x] Add Code Coverage (codecov) + - [x] Add Jest Unit tests + - [x] Add Jest Code Coverage (codecov) - [x] Build and run on every PR + - [x] Add full bundler (all types) build step in CircleCI build - [x] Bundle Creation (vanilla bundle) - - [ ] Eventually add Unit Tests as a PreBundle task -- [ ] Remove any Deprecated code + - [ ] Eventually add Unit Tests as a Pre-Bundle task +- [x] Remove any Deprecated code - [ ] Create a [Migration Guide](https://github.com/ghiscoding/slickgrid-universal/wiki/Migration-for-Angular-Aurelia-Slickgrid) for Angular/Aurelia - [x] Add simple input bindings in the demo (e.g. pinned rows input) - [x] Add possibility to use SVG instead of Font Family - [x] Add Typings (interfaces) for Slick Grid & DataView objects - [x] Add interfaces to all SlickGrid core lib classes & plugins (basically add Types to everything) -- [ ] 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) +- [ ] Copy text from cell doesn't work in SF +- [ ] Remove all Services init method 2nd argument (we can get DataView directly from the Grid object) diff --git a/docs/app.0d425d500c9a48ed1158.bundle.js b/docs/app.0d425d500c9a48ed1158.bundle.js deleted file mode 100644 index 66b6e1905..000000000 --- a/docs/app.0d425d500c9a48ed1158.bundle.js +++ /dev/null @@ -1,297 +0,0 @@ -!function(e){var t={};function _(i){if(t[i])return t[i].exports;var r=t[i]={i:i,l:!1,exports:{}};return e[i].call(r.exports,r,r.exports,_),r.l=!0,r.exports}_.m=e,_.c=t,_.d=function(e,t,i){_.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:i})},_.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},_.t=function(e,t){if(1&t&&(e=_(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var i=Object.create(null);if(_.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var r in e)_.d(i,r,function(t){return e[t]}.bind(null,r));return i},_.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return _.d(t,"a",t),t},_.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},_.p="",_(_.s=209)}([function(e,t,_){"use strict";_.r(t);var i=_(55);_.d(t,"DelimiterType",(function(){return i.a}));var r=_(38);_.d(t,"EmitterType",(function(){return r.a}));var n=_(56);_.d(t,"EventNamingStyle",(function(){return n.a}));var o=_(5);_.d(t,"ExtensionName",(function(){return o.a}));var c=_(8);_.d(t,"FieldType",(function(){return c.a}));var a=_(57);_.d(t,"FileType",(function(){return a.a}));var l=_(58);_.d(t,"FilterMultiplePassType",(function(){return l.a}));var s=_(59);for(var d in s)["DelimiterType","EmitterType","EventNamingStyle","ExtensionName","FieldType","FileType","FilterMultiplePassType","default"].indexOf(d)<0&&function(e){_.d(t,e,(function(){return s[e]}))}(d);var u=_(60);_.d(t,"GridAutosizeColsMode",(function(){return u.a}));var m=_(61);_.d(t,"GridStateType",(function(){return m.a}));var p=_(13);_.d(t,"KeyCode",(function(){return p.a}));var f=_(62);for(var d in f)["DelimiterType","EmitterType","EventNamingStyle","ExtensionName","FieldType","FileType","FilterMultiplePassType","GridAutosizeColsMode","GridStateType","KeyCode","default"].indexOf(d)<0&&function(e){_.d(t,e,(function(){return f[e]}))}(d);var h=_(28);_.d(t,"OperatorType",(function(){return h.a}));var P=_(63);for(var d in P)["DelimiterType","EmitterType","EventNamingStyle","ExtensionName","FieldType","FileType","FilterMultiplePassType","GridAutosizeColsMode","GridStateType","KeyCode","OperatorType","default"].indexOf(d)<0&&function(e){_.d(t,e,(function(){return P[e]}))}(d);var g=_(64);_.d(t,"SortDirection",(function(){return g.a}));var E=_(27);_.d(t,"SortDirectionNumber",(function(){return E.a}));var b=_(65);for(var d in b)["DelimiterType","EmitterType","EventNamingStyle","ExtensionName","FieldType","FileType","FilterMultiplePassType","GridAutosizeColsMode","GridStateType","KeyCode","OperatorType","SortDirection","SortDirectionNumber","default"].indexOf(d)<0&&function(e){_.d(t,e,(function(){return b[e]}))}(d)},function(e,t,_){"use strict";_.d(t,"Utilities",(function(){return b}));_(221);var i=_(7);_.d(t,"Constants",(function(){return i.a}));var r=_(37);_.d(t,"GlobalGridOptions",(function(){return r.a}));var n=_(0);_.o(n,"Aggregators")&&_.d(t,"Aggregators",(function(){return n.Aggregators})),_.o(n,"AutoTooltipExtension")&&_.d(t,"AutoTooltipExtension",(function(){return n.AutoTooltipExtension})),_.o(n,"CellExternalCopyManagerExtension")&&_.d(t,"CellExternalCopyManagerExtension",(function(){return n.CellExternalCopyManagerExtension})),_.o(n,"CellMenuExtension")&&_.d(t,"CellMenuExtension",(function(){return n.CellMenuExtension})),_.o(n,"CheckboxSelectorExtension")&&_.d(t,"CheckboxSelectorExtension",(function(){return n.CheckboxSelectorExtension})),_.o(n,"CollectionService")&&_.d(t,"CollectionService",(function(){return n.CollectionService})),_.o(n,"ColumnPickerExtension")&&_.d(t,"ColumnPickerExtension",(function(){return n.ColumnPickerExtension})),_.o(n,"ContextMenuExtension")&&_.d(t,"ContextMenuExtension",(function(){return n.ContextMenuExtension})),_.o(n,"DraggableGroupingExtension")&&_.d(t,"DraggableGroupingExtension",(function(){return n.DraggableGroupingExtension})),_.o(n,"Editors")&&_.d(t,"Editors",(function(){return n.Editors})),_.o(n,"EventNamingStyle")&&_.d(t,"EventNamingStyle",(function(){return n.EventNamingStyle})),_.o(n,"ExtensionName")&&_.d(t,"ExtensionName",(function(){return n.ExtensionName})),_.o(n,"ExtensionService")&&_.d(t,"ExtensionService",(function(){return n.ExtensionService})),_.o(n,"ExtensionUtility")&&_.d(t,"ExtensionUtility",(function(){return n.ExtensionUtility})),_.o(n,"FieldType")&&_.d(t,"FieldType",(function(){return n.FieldType})),_.o(n,"FileType")&&_.d(t,"FileType",(function(){return n.FileType})),_.o(n,"FilterFactory")&&_.d(t,"FilterFactory",(function(){return n.FilterFactory})),_.o(n,"FilterService")&&_.d(t,"FilterService",(function(){return n.FilterService})),_.o(n,"Filters")&&_.d(t,"Filters",(function(){return n.Filters})),_.o(n,"Formatters")&&_.d(t,"Formatters",(function(){return n.Formatters})),_.o(n,"GridEventService")&&_.d(t,"GridEventService",(function(){return n.GridEventService})),_.o(n,"GridMenuExtension")&&_.d(t,"GridMenuExtension",(function(){return n.GridMenuExtension})),_.o(n,"GridService")&&_.d(t,"GridService",(function(){return n.GridService})),_.o(n,"GridStateService")&&_.d(t,"GridStateService",(function(){return n.GridStateService})),_.o(n,"GroupItemMetaProviderExtension")&&_.d(t,"GroupItemMetaProviderExtension",(function(){return n.GroupItemMetaProviderExtension})),_.o(n,"GroupTotalFormatters")&&_.d(t,"GroupTotalFormatters",(function(){return n.GroupTotalFormatters})),_.o(n,"GroupingAndColspanService")&&_.d(t,"GroupingAndColspanService",(function(){return n.GroupingAndColspanService})),_.o(n,"HeaderButtonExtension")&&_.d(t,"HeaderButtonExtension",(function(){return n.HeaderButtonExtension})),_.o(n,"HeaderMenuExtension")&&_.d(t,"HeaderMenuExtension",(function(){return n.HeaderMenuExtension})),_.o(n,"OperatorType")&&_.d(t,"OperatorType",(function(){return n.OperatorType})),_.o(n,"PaginationService")&&_.d(t,"PaginationService",(function(){return n.PaginationService})),_.o(n,"RowMoveManagerExtension")&&_.d(t,"RowMoveManagerExtension",(function(){return n.RowMoveManagerExtension})),_.o(n,"RowSelectionExtension")&&_.d(t,"RowSelectionExtension",(function(){return n.RowSelectionExtension})),_.o(n,"SharedService")&&_.d(t,"SharedService",(function(){return n.SharedService})),_.o(n,"SortComparers")&&_.d(t,"SortComparers",(function(){return n.SortComparers})),_.o(n,"SortDirectionNumber")&&_.d(t,"SortDirectionNumber",(function(){return n.SortDirectionNumber})),_.o(n,"SortService")&&_.d(t,"SortService",(function(){return n.SortService})),_.o(n,"TreeDataService")&&_.d(t,"TreeDataService",(function(){return n.TreeDataService})),_.o(n,"addWhiteSpaces")&&_.d(t,"addWhiteSpaces",(function(){return n.addWhiteSpaces})),_.o(n,"convertParentChildArrayToHierarchicalView")&&_.d(t,"convertParentChildArrayToHierarchicalView",(function(){return n.convertParentChildArrayToHierarchicalView})),_.o(n,"deepCopy")&&_.d(t,"deepCopy",(function(){return n.deepCopy})),_.o(n,"exportWithFormatterWhenDefined")&&_.d(t,"exportWithFormatterWhenDefined",(function(){return n.exportWithFormatterWhenDefined})),_.o(n,"findItemInHierarchicalStructure")&&_.d(t,"findItemInHierarchicalStructure",(function(){return n.findItemInHierarchicalStructure})),_.o(n,"getTranslationPrefix")&&_.d(t,"getTranslationPrefix",(function(){return n.getTranslationPrefix})),_.o(n,"htmlEntityDecode")&&_.d(t,"htmlEntityDecode",(function(){return n.htmlEntityDecode})),_.o(n,"mapMomentDateFormatWithFieldType")&&_.d(t,"mapMomentDateFormatWithFieldType",(function(){return n.mapMomentDateFormatWithFieldType})),_.o(n,"sanitizeHtmlToText")&&_.d(t,"sanitizeHtmlToText",(function(){return n.sanitizeHtmlToText})),_.o(n,"titleCase")&&_.d(t,"titleCase",(function(){return n.titleCase})),_.o(n,"toKebabCase")&&_.d(t,"toKebabCase",(function(){return n.toKebabCase}));var o=_(67);_.o(o,"Aggregators")&&_.d(t,"Aggregators",(function(){return o.Aggregators})),_.o(o,"AutoTooltipExtension")&&_.d(t,"AutoTooltipExtension",(function(){return o.AutoTooltipExtension})),_.o(o,"CellExternalCopyManagerExtension")&&_.d(t,"CellExternalCopyManagerExtension",(function(){return o.CellExternalCopyManagerExtension})),_.o(o,"CellMenuExtension")&&_.d(t,"CellMenuExtension",(function(){return o.CellMenuExtension})),_.o(o,"CheckboxSelectorExtension")&&_.d(t,"CheckboxSelectorExtension",(function(){return o.CheckboxSelectorExtension})),_.o(o,"CollectionService")&&_.d(t,"CollectionService",(function(){return o.CollectionService})),_.o(o,"ColumnPickerExtension")&&_.d(t,"ColumnPickerExtension",(function(){return o.ColumnPickerExtension})),_.o(o,"ContextMenuExtension")&&_.d(t,"ContextMenuExtension",(function(){return o.ContextMenuExtension})),_.o(o,"DraggableGroupingExtension")&&_.d(t,"DraggableGroupingExtension",(function(){return o.DraggableGroupingExtension})),_.o(o,"Editors")&&_.d(t,"Editors",(function(){return o.Editors})),_.o(o,"EventNamingStyle")&&_.d(t,"EventNamingStyle",(function(){return o.EventNamingStyle})),_.o(o,"ExtensionName")&&_.d(t,"ExtensionName",(function(){return o.ExtensionName})),_.o(o,"ExtensionService")&&_.d(t,"ExtensionService",(function(){return o.ExtensionService})),_.o(o,"ExtensionUtility")&&_.d(t,"ExtensionUtility",(function(){return o.ExtensionUtility})),_.o(o,"FieldType")&&_.d(t,"FieldType",(function(){return o.FieldType})),_.o(o,"FileType")&&_.d(t,"FileType",(function(){return o.FileType})),_.o(o,"FilterFactory")&&_.d(t,"FilterFactory",(function(){return o.FilterFactory})),_.o(o,"FilterService")&&_.d(t,"FilterService",(function(){return o.FilterService})),_.o(o,"Filters")&&_.d(t,"Filters",(function(){return o.Filters})),_.o(o,"Formatters")&&_.d(t,"Formatters",(function(){return o.Formatters})),_.o(o,"GridEventService")&&_.d(t,"GridEventService",(function(){return o.GridEventService})),_.o(o,"GridMenuExtension")&&_.d(t,"GridMenuExtension",(function(){return o.GridMenuExtension})),_.o(o,"GridService")&&_.d(t,"GridService",(function(){return o.GridService})),_.o(o,"GridStateService")&&_.d(t,"GridStateService",(function(){return o.GridStateService})),_.o(o,"GroupItemMetaProviderExtension")&&_.d(t,"GroupItemMetaProviderExtension",(function(){return o.GroupItemMetaProviderExtension})),_.o(o,"GroupTotalFormatters")&&_.d(t,"GroupTotalFormatters",(function(){return o.GroupTotalFormatters})),_.o(o,"GroupingAndColspanService")&&_.d(t,"GroupingAndColspanService",(function(){return o.GroupingAndColspanService})),_.o(o,"HeaderButtonExtension")&&_.d(t,"HeaderButtonExtension",(function(){return o.HeaderButtonExtension})),_.o(o,"HeaderMenuExtension")&&_.d(t,"HeaderMenuExtension",(function(){return o.HeaderMenuExtension})),_.o(o,"OperatorType")&&_.d(t,"OperatorType",(function(){return o.OperatorType})),_.o(o,"PaginationService")&&_.d(t,"PaginationService",(function(){return o.PaginationService})),_.o(o,"RowMoveManagerExtension")&&_.d(t,"RowMoveManagerExtension",(function(){return o.RowMoveManagerExtension})),_.o(o,"RowSelectionExtension")&&_.d(t,"RowSelectionExtension",(function(){return o.RowSelectionExtension})),_.o(o,"SharedService")&&_.d(t,"SharedService",(function(){return o.SharedService})),_.o(o,"SortComparers")&&_.d(t,"SortComparers",(function(){return o.SortComparers})),_.o(o,"SortDirectionNumber")&&_.d(t,"SortDirectionNumber",(function(){return o.SortDirectionNumber})),_.o(o,"SortService")&&_.d(t,"SortService",(function(){return o.SortService})),_.o(o,"TreeDataService")&&_.d(t,"TreeDataService",(function(){return o.TreeDataService})),_.o(o,"addWhiteSpaces")&&_.d(t,"addWhiteSpaces",(function(){return o.addWhiteSpaces})),_.o(o,"convertParentChildArrayToHierarchicalView")&&_.d(t,"convertParentChildArrayToHierarchicalView",(function(){return o.convertParentChildArrayToHierarchicalView})),_.o(o,"deepCopy")&&_.d(t,"deepCopy",(function(){return o.deepCopy})),_.o(o,"exportWithFormatterWhenDefined")&&_.d(t,"exportWithFormatterWhenDefined",(function(){return o.exportWithFormatterWhenDefined})),_.o(o,"findItemInHierarchicalStructure")&&_.d(t,"findItemInHierarchicalStructure",(function(){return o.findItemInHierarchicalStructure})),_.o(o,"getTranslationPrefix")&&_.d(t,"getTranslationPrefix",(function(){return o.getTranslationPrefix})),_.o(o,"htmlEntityDecode")&&_.d(t,"htmlEntityDecode",(function(){return o.htmlEntityDecode})),_.o(o,"mapMomentDateFormatWithFieldType")&&_.d(t,"mapMomentDateFormatWithFieldType",(function(){return o.mapMomentDateFormatWithFieldType})),_.o(o,"sanitizeHtmlToText")&&_.d(t,"sanitizeHtmlToText",(function(){return o.sanitizeHtmlToText})),_.o(o,"titleCase")&&_.d(t,"titleCase",(function(){return o.titleCase})),_.o(o,"toKebabCase")&&_.d(t,"toKebabCase",(function(){return o.toKebabCase}));var c=_(16);_.d(t,"CollectionService",(function(){return c.a})),_.d(t,"ExtensionService",(function(){return c.b})),_.d(t,"FilterService",(function(){return c.c})),_.d(t,"GridEventService",(function(){return c.d})),_.d(t,"GridService",(function(){return c.e})),_.d(t,"GridStateService",(function(){return c.f})),_.d(t,"GroupingAndColspanService",(function(){return c.g})),_.d(t,"PaginationService",(function(){return c.h})),_.d(t,"SharedService",(function(){return c.i})),_.d(t,"SortService",(function(){return c.j})),_.d(t,"TreeDataService",(function(){return c.k})),_.d(t,"addWhiteSpaces",(function(){return c.l})),_.d(t,"convertParentChildArrayToHierarchicalView",(function(){return c.m})),_.d(t,"deepCopy",(function(){return c.n})),_.d(t,"exportWithFormatterWhenDefined",(function(){return c.o})),_.d(t,"findItemInHierarchicalStructure",(function(){return c.p})),_.d(t,"getTranslationPrefix",(function(){return c.r})),_.d(t,"htmlEntityDecode",(function(){return c.s})),_.d(t,"mapMomentDateFormatWithFieldType",(function(){return c.t})),_.d(t,"sanitizeHtmlToText",(function(){return c.u})),_.d(t,"titleCase",(function(){return c.v})),_.d(t,"toKebabCase",(function(){return c.w}));var a=_(194);_.d(t,"Aggregators",(function(){return a.a}));var l=_(193);_.d(t,"Editors",(function(){return l.a}));var s=_(191);_.d(t,"AutoTooltipExtension",(function(){return s.a})),_.d(t,"CellExternalCopyManagerExtension",(function(){return s.b})),_.d(t,"CellMenuExtension",(function(){return s.c})),_.d(t,"CheckboxSelectorExtension",(function(){return s.d})),_.d(t,"ColumnPickerExtension",(function(){return s.e})),_.d(t,"ContextMenuExtension",(function(){return s.f})),_.d(t,"DraggableGroupingExtension",(function(){return s.g})),_.d(t,"ExtensionUtility",(function(){return s.h})),_.d(t,"GridMenuExtension",(function(){return s.i})),_.d(t,"GroupItemMetaProviderExtension",(function(){return s.j})),_.d(t,"HeaderButtonExtension",(function(){return s.k})),_.d(t,"HeaderMenuExtension",(function(){return s.l})),_.d(t,"RowMoveManagerExtension",(function(){return s.m})),_.d(t,"RowSelectionExtension",(function(){return s.n}));_(35);var d=_(45);_.d(t,"Filters",(function(){return d.a}));var u=_(174);_.d(t,"FilterFactory",(function(){return u.a}));var m=_(190);_.d(t,"Formatters",(function(){return m.a}));var p=_(192);_.d(t,"GroupTotalFormatters",(function(){return p.a}));var f=_(31);_.d(t,"SortComparers",(function(){return f.a})),_.d(t,"Enums",(function(){return n}));var h=_(2),P=_(18),g=_(175);_.d(t,"SlickgridConfig",(function(){return g.a}));var E=function(){return(E=Object.assign||function(e){for(var t,_=1,i=arguments.length;_":">",'"':""","'":"'"};return(e||"").toString().replace(/[&<>"']/g,(function(e){return t[e]}))}function p(e){return e.replace(/&#(\d+);/g,(function(e,t){return String.fromCharCode(t)}))}function f(e,t,_,i,r){if(void 0===i&&(i="."),void 0===r&&(r=""),isNaN(+e))return e;var n=void 0===t?2:t,o=void 0===_?2:_,c=String(Math.round(+e*Math.pow(10,o))/Math.pow(10,o));for(c.indexOf(".")<0&&n>0&&(c+=".");c.length-c.indexOf(".")<=n;)c+="0";var a,l,s=c.split(".");a=r?s.length>=1?M(s[0],r):void 0:s.length>=1?s[0]:c,s.length>1&&(l=s[1]);var d="";return void 0!==a&&void 0!==l?d=""+a+i+l:null!=a&&(d=a),d}function h(e,t,_,i,r,n,o,c){if(void 0===r&&(r=""),void 0===n&&(n=""),void 0===o&&(o="."),void 0===c&&(c=""),isNaN(+e))return e;var a=Math.round(1e6*parseFloat(e))/1e6;if(a<0){var l=Math.abs(a);return i?isNaN(t)&&isNaN(_)?"("+r+M(""+l,c)+n+")":"("+r+f(l,t,_,o,c)+n+")":isNaN(t)&&isNaN(_)?"-"+r+M(""+l,c)+n:"-"+r+f(l,t,_,o,c)+n}return isNaN(t)&&isNaN(_)?""+r+M(""+e,c)+n:""+r+f(e,t,_,o,c)+n}function P(e,t){return e&&t?t.split(".").reduce((function(e,t){return e&&e[t]}),e):e}function g(e){return e&&e.translationNamespace?e.translationNamespace+(e.translationNamespaceSeparator||""):""}function E(e){var t;switch(e){case r.FieldType.dateTime:case r.FieldType.dateTimeIso:t="YYYY-MM-DD HH:mm:ss";break;case r.FieldType.dateTimeIsoAmPm:t="YYYY-MM-DD hh:mm:ss a";break;case r.FieldType.dateTimeIsoAM_PM:t="YYYY-MM-DD hh:mm:ss A";break;case r.FieldType.dateTimeShortIso:t="YYYY-MM-DD HH:mm";break;case r.FieldType.dateEuro:t="DD/MM/YYYY";break;case r.FieldType.dateEuroShort:t="D/M/YY";break;case r.FieldType.dateTimeEuro:t="DD/MM/YYYY HH:mm:ss";break;case r.FieldType.dateTimeShortEuro:t="DD/MM/YYYY HH:mm";break;case r.FieldType.dateTimeEuroAmPm:t="DD/MM/YYYY hh:mm:ss a";break;case r.FieldType.dateTimeEuroAM_PM:t="DD/MM/YYYY hh:mm:ss A";break;case r.FieldType.dateTimeEuroShort:t="D/M/YY H:m:s";break;case r.FieldType.dateTimeEuroShortAmPm:t="D/M/YY h:m:s a";break;case r.FieldType.dateUs:t="MM/DD/YYYY";break;case r.FieldType.dateUsShort:t="M/D/YY";break;case r.FieldType.dateTimeUs:t="MM/DD/YYYY HH:mm:ss";break;case r.FieldType.dateTimeUsAmPm:t="MM/DD/YYYY hh:mm:ss a";break;case r.FieldType.dateTimeUsAM_PM:t="MM/DD/YYYY hh:mm:ss A";break;case r.FieldType.dateTimeUsShort:t="M/D/YY H:m:s";break;case r.FieldType.dateTimeUsShortAmPm:t="M/D/YY h:m:s a";break;case r.FieldType.dateTimeShortUs:t="MM/DD/YYYY HH:mm";break;case r.FieldType.dateUtc:t="YYYY-MM-DDTHH:mm:ss.SSSZ";break;case r.FieldType.date:case r.FieldType.dateIso:default:t="YYYY-MM-DD"}return t}function b(e){var t;switch(e){case r.FieldType.dateTime:case r.FieldType.dateTimeIso:t="Y-m-d H:i:S";break;case r.FieldType.dateTimeShortIso:t="Y-m-d H:i";break;case r.FieldType.dateTimeIsoAmPm:case r.FieldType.dateTimeIsoAM_PM:t="Y-m-d h:i:S K";break;case r.FieldType.dateEuro:t="d/m/Y";break;case r.FieldType.dateEuroShort:t="d/m/y";break;case r.FieldType.dateTimeEuro:t="d/m/Y H:i:S";break;case r.FieldType.dateTimeShortEuro:t="d/m/y H:i";break;case r.FieldType.dateTimeEuroAmPm:t="d/m/Y h:i:S K";break;case r.FieldType.dateTimeEuroAM_PM:t="d/m/Y h:i:s K";break;case r.FieldType.dateTimeEuroShort:t="d/m/y H:i:s";break;case r.FieldType.dateTimeEuroShortAmPm:t="d/m/y h:i:s K";break;case r.FieldType.dateUs:t="m/d/Y";break;case r.FieldType.dateUsShort:t="m/d/y";break;case r.FieldType.dateTimeUs:t="m/d/Y H:i:S";break;case r.FieldType.dateTimeShortUs:t="m/d/y H:i";break;case r.FieldType.dateTimeUsAmPm:t="m/d/Y h:i:S K";break;case r.FieldType.dateTimeUsAM_PM:t="m/d/Y h:i:s K";break;case r.FieldType.dateTimeUsShort:t="m/d/y H:i:s";break;case r.FieldType.dateTimeUsShortAmPm:t="m/d/y h:i:s K";break;case r.FieldType.dateUtc:t="Z";break;case r.FieldType.date:case r.FieldType.dateIso:default:t="Y-m-d"}return t}function A(e){var t;switch(e){case"<":case"LT":t=r.OperatorType.lessThan;break;case"<=":case"LE":t=r.OperatorType.lessThanOrEqual;break;case">":case"GT":t=r.OperatorType.greaterThan;break;case">=":case"GE":t=r.OperatorType.greaterThanOrEqual;break;case"<>":case"!=":case"NE":t=r.OperatorType.notEqual;break;case"*":case"a*":case"StartsWith":t=r.OperatorType.startsWith;break;case"*z":case"EndsWith":t=r.OperatorType.endsWith;break;case"=":case"==":case"EQ":t=r.OperatorType.equal;break;case"IN":t=r.OperatorType.in;break;case"NIN":case"NOT_IN":t=r.OperatorType.notIn;break;case"Not_Contains":case"NOT_CONTAINS":t=r.OperatorType.notContains;break;case"Contains":case"CONTAINS":default:t=r.OperatorType.contains}return t}function N(e){var t="";switch(e){case r.OperatorType.greaterThan:case">":t=">";break;case r.OperatorType.greaterThanOrEqual:case">=":t=">=";break;case r.OperatorType.lessThan:case"<":t="<";break;case r.OperatorType.lessThanOrEqual:case"<=":t="<=";break;case r.OperatorType.notEqual:case"<>":t="<>";break;case r.OperatorType.equal:case"=":case"==":case"EQ":t="=";break;case r.OperatorType.startsWith:case"a*":case"*":t="a*";break;case r.OperatorType.endsWith:case"*z":t="*z";break;default:t=e}return t}function x(e){var t;switch(e){case r.FieldType.string:case r.FieldType.unknown:t=r.OperatorType.contains;break;case r.FieldType.float:case r.FieldType.number:case r.FieldType.date:case r.FieldType.dateIso:case r.FieldType.dateUtc:case r.FieldType.dateTime:case r.FieldType.dateTimeIso:case r.FieldType.dateTimeIsoAmPm:case r.FieldType.dateTimeIsoAM_PM:case r.FieldType.dateEuro:case r.FieldType.dateEuroShort:case r.FieldType.dateTimeEuro:case r.FieldType.dateTimeEuroAmPm:case r.FieldType.dateTimeEuroAM_PM:case r.FieldType.dateTimeEuroShort:case r.FieldType.dateTimeEuroShortAmPm:case r.FieldType.dateTimeEuroShortAM_PM:case r.FieldType.dateUs:case r.FieldType.dateUsShort:case r.FieldType.dateTimeUs:case r.FieldType.dateTimeUsAmPm:case r.FieldType.dateTimeUsAM_PM:case r.FieldType.dateTimeUsShort:case r.FieldType.dateTimeUsShortAmPm:case r.FieldType.dateTimeUsShortAM_PM:default:t=r.OperatorType.equal}return t}function S(e){return/(true|1)/i.test(e+"")}function y(e,t){var _="";if("string"==typeof e&&/^[0-9\-\/]*$/.test(e)){var i=decodeURIComponent(e),r=n(new Date(i));r.isValid()&&4===r.year().toString().length&&(_=t?r.utc().format():r.format())}return _}function v(e){var t=document.createElement("div");return t.innerHTML=e,t.textContent||t.innerText||""}function C(e,t,_){if("string"==typeof t&&(t=t.split(".")),t.length>1){var i=t.shift();e&&void 0!==i&&C(e[i]="[object Object]"===Object.prototype.toString.call(e[i])?e[i]:{},t,_)}else e&&t[0]&&(e[t[0]]=_)}function M(e,t){if(void 0===t&&(t=","),null!=e){var _=""+e,i=_.split(".");return 2===i.length?i[0].replace(/\B(?=(\d{3})+(?!\d))/g,t)+"."+i[1]:_.replace(/\B(?=(\d{3})+(?!\d))/g,t)}return e}function R(e,t){return void 0===t&&(t=!1),"string"==typeof e?t?e.replace(/\w\S*/g,(function(e){return e.charAt(0).toUpperCase()+e.substr(1).toLowerCase()})):e.charAt(0).toUpperCase()+e.slice(1):e}function I(e){return"string"==typeof e?e.replace(/(?:^\w|[A-Z]|\b\w|[\s+\-_\/])/g,(function(e,t){return/[\s+\-_\/]/.test(e)?"":0===t?e.toLowerCase():e.toUpperCase()})):e}function T(e){return"string"==typeof e?I(e).replace(/([A-Z])/g,"-$1").toLowerCase():e}function B(e,t,_){if(void 0===_&&(_=!1),!(e&&t&&Array.isArray(e)&&Array.isArray(e)))return!1;if(e.length!==t.length)return!1;!_&&e.sort&&t.sort&&(e.sort(),t.sort());for(var i=0;i0?e.filter((function(t,_){return e.indexOf(t)>=_})):e}function k(e,t){if(void 0===t&&(t="id"),Array.isArray(e)&&e.length>0){for(var _=[],i=new Map,r=0,n=e;r0&&t-1 in e)}x.fn=x.prototype={jquery:"3.4.1",constructor:x,length:0,toArray:function(){return a.call(this)},get:function(e){return null==e?a.call(this):e<0?this[e+this.length]:this[e]},pushStack:function(e){var t=x.merge(this.constructor(),e);return t.prevObject=this,t},each:function(e){return x.each(this,e)},map:function(e){return this.pushStack(x.map(this,(function(t,_){return e.call(t,_,t)})))},slice:function(){return this.pushStack(a.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(e){var t=this.length,_=+e+(e<0?t:0);return this.pushStack(_>=0&&_+~]|"+k+")"+k+"*"),W=new RegExp(k+"|>"),V=new RegExp(U),K=new RegExp("^"+Q+"$"),J={ID:new RegExp("^#("+Q+")"),CLASS:new RegExp("^\\.("+Q+")"),TAG:new RegExp("^("+Q+"|[*])"),ATTR:new RegExp("^"+O),PSEUDO:new RegExp("^"+U),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+k+"*(even|odd|(([+-]|)(\\d*)n|)"+k+"*(?:([+-]|)"+k+"*(\\d+)|))"+k+"*\\)|)","i"),bool:new RegExp("^(?:"+L+")$","i"),needsContext:new RegExp("^"+k+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+k+"*((?:-\\d)?\\d*)"+k+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,j=/^(?:input|select|textarea|button)$/i,X=/^h\d$/i,$=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\([\\da-f]{1,6}"+k+"?|("+k+")|.)","ig"),_e=function(e,t,_){var i="0x"+t-65536;return i!=i||_?t:i<0?String.fromCharCode(i+65536):String.fromCharCode(i>>10|55296,1023&i|56320)},ie=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,re=function(e,t){return t?"\0"===e?"�":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},ne=function(){u()},oe=be((function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()}),{dir:"parentNode",next:"legend"});try{F.apply(I=w.call(A.childNodes),A.childNodes),I[A.childNodes.length].nodeType}catch(e){F={apply:I.length?function(e,t){B.apply(e,w.call(t))}:function(e,t){for(var _=e.length,i=0;e[_++]=t[i++];);e.length=_-1}}}function ce(e,t,i,r){var n,c,l,s,d,p,P,g=t&&t.ownerDocument,N=t?t.nodeType:9;if(i=i||[],"string"!=typeof e||!e||1!==N&&9!==N&&11!==N)return i;if(!r&&((t?t.ownerDocument||t:A)!==m&&u(t),t=t||m,f)){if(11!==N&&(d=Z.exec(e)))if(n=d[1]){if(9===N){if(!(l=t.getElementById(n)))return i;if(l.id===n)return i.push(l),i}else if(g&&(l=g.getElementById(n))&&E(t,l)&&l.id===n)return i.push(l),i}else{if(d[2])return F.apply(i,t.getElementsByTagName(e)),i;if((n=d[3])&&_.getElementsByClassName&&t.getElementsByClassName)return F.apply(i,t.getElementsByClassName(n)),i}if(_.qsa&&!C[e+" "]&&(!h||!h.test(e))&&(1!==N||"object"!==t.nodeName.toLowerCase())){if(P=e,g=t,1===N&&W.test(e)){for((s=t.getAttribute("id"))?s=s.replace(ie,re):t.setAttribute("id",s=b),c=(p=o(e)).length;c--;)p[c]="#"+s+" "+Ee(p[c]);P=p.join(","),g=ee.test(e)&&Pe(t.parentNode)||t}try{return F.apply(i,g.querySelectorAll(P)),i}catch(t){C(e,!0)}finally{s===b&&t.removeAttribute("id")}}}return a(e.replace(H,"$1"),t,i,r)}function ae(){var e=[];return function t(_,r){return e.push(_+" ")>i.cacheLength&&delete t[e.shift()],t[_+" "]=r}}function le(e){return e[b]=!0,e}function se(e){var t=m.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function de(e,t){for(var _=e.split("|"),r=_.length;r--;)i.attrHandle[_[r]]=t}function ue(e,t){var _=t&&e,i=_&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(i)return i;if(_)for(;_=_.nextSibling;)if(_===t)return-1;return e?1:-1}function me(e){return function(t){return"input"===t.nodeName.toLowerCase()&&t.type===e}}function pe(e){return function(t){var _=t.nodeName.toLowerCase();return("input"===_||"button"===_)&&t.type===e}}function fe(e){return function(t){return"form"in t?t.parentNode&&!1===t.disabled?"label"in t?"label"in t.parentNode?t.parentNode.disabled===e:t.disabled===e:t.isDisabled===e||t.isDisabled!==!e&&oe(t)===e:t.disabled===e:"label"in t&&t.disabled===e}}function he(e){return le((function(t){return t=+t,le((function(_,i){for(var r,n=e([],_.length,t),o=n.length;o--;)_[r=n[o]]&&(_[r]=!(i[r]=_[r]))}))}))}function Pe(e){return e&&void 0!==e.getElementsByTagName&&e}for(t in _=ce.support={},n=ce.isXML=function(e){var t=e.namespaceURI,_=(e.ownerDocument||e).documentElement;return!Y.test(t||_&&_.nodeName||"HTML")},u=ce.setDocument=function(e){var t,r,o=e?e.ownerDocument||e:A;return o!==m&&9===o.nodeType&&o.documentElement?(p=(m=o).documentElement,f=!n(m),A!==m&&(r=m.defaultView)&&r.top!==r&&(r.addEventListener?r.addEventListener("unload",ne,!1):r.attachEvent&&r.attachEvent("onunload",ne)),_.attributes=se((function(e){return e.className="i",!e.getAttribute("className")})),_.getElementsByTagName=se((function(e){return e.appendChild(m.createComment("")),!e.getElementsByTagName("*").length})),_.getElementsByClassName=$.test(m.getElementsByClassName),_.getById=se((function(e){return p.appendChild(e).id=b,!m.getElementsByName||!m.getElementsByName(b).length})),_.getById?(i.filter.ID=function(e){var t=e.replace(te,_e);return function(e){return e.getAttribute("id")===t}},i.find.ID=function(e,t){if(void 0!==t.getElementById&&f){var _=t.getElementById(e);return _?[_]:[]}}):(i.filter.ID=function(e){var t=e.replace(te,_e);return function(e){var _=void 0!==e.getAttributeNode&&e.getAttributeNode("id");return _&&_.value===t}},i.find.ID=function(e,t){if(void 0!==t.getElementById&&f){var _,i,r,n=t.getElementById(e);if(n){if((_=n.getAttributeNode("id"))&&_.value===e)return[n];for(r=t.getElementsByName(e),i=0;n=r[i++];)if((_=n.getAttributeNode("id"))&&_.value===e)return[n]}return[]}}),i.find.TAG=_.getElementsByTagName?function(e,t){return void 0!==t.getElementsByTagName?t.getElementsByTagName(e):_.qsa?t.querySelectorAll(e):void 0}:function(e,t){var _,i=[],r=0,n=t.getElementsByTagName(e);if("*"===e){for(;_=n[r++];)1===_.nodeType&&i.push(_);return i}return n},i.find.CLASS=_.getElementsByClassName&&function(e,t){if(void 0!==t.getElementsByClassName&&f)return t.getElementsByClassName(e)},P=[],h=[],(_.qsa=$.test(m.querySelectorAll))&&(se((function(e){p.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&h.push("[*^$]="+k+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||h.push("\\["+k+"*(?:value|"+L+")"),e.querySelectorAll("[id~="+b+"-]").length||h.push("~="),e.querySelectorAll(":checked").length||h.push(":checked"),e.querySelectorAll("a#"+b+"+*").length||h.push(".#.+[+~]")})),se((function(e){e.innerHTML="";var t=m.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&h.push("name"+k+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&h.push(":enabled",":disabled"),p.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&h.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),h.push(",.*:")}))),(_.matchesSelector=$.test(g=p.matches||p.webkitMatchesSelector||p.mozMatchesSelector||p.oMatchesSelector||p.msMatchesSelector))&&se((function(e){_.disconnectedMatch=g.call(e,"*"),g.call(e,"[s!='']:x"),P.push("!=",U)})),h=h.length&&new RegExp(h.join("|")),P=P.length&&new RegExp(P.join("|")),t=$.test(p.compareDocumentPosition),E=t||$.test(p.contains)?function(e,t){var _=9===e.nodeType?e.documentElement:e,i=t&&t.parentNode;return e===i||!(!i||1!==i.nodeType||!(_.contains?_.contains(i):e.compareDocumentPosition&&16&e.compareDocumentPosition(i)))}:function(e,t){if(t)for(;t=t.parentNode;)if(t===e)return!0;return!1},M=t?function(e,t){if(e===t)return d=!0,0;var i=!e.compareDocumentPosition-!t.compareDocumentPosition;return i||(1&(i=(e.ownerDocument||e)===(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!_.sortDetached&&t.compareDocumentPosition(e)===i?e===m||e.ownerDocument===A&&E(A,e)?-1:t===m||t.ownerDocument===A&&E(A,t)?1:s?D(s,e)-D(s,t):0:4&i?-1:1)}:function(e,t){if(e===t)return d=!0,0;var _,i=0,r=e.parentNode,n=t.parentNode,o=[e],c=[t];if(!r||!n)return e===m?-1:t===m?1:r?-1:n?1:s?D(s,e)-D(s,t):0;if(r===n)return ue(e,t);for(_=e;_=_.parentNode;)o.unshift(_);for(_=t;_=_.parentNode;)c.unshift(_);for(;o[i]===c[i];)i++;return i?ue(o[i],c[i]):o[i]===A?-1:c[i]===A?1:0},m):m},ce.matches=function(e,t){return ce(e,null,null,t)},ce.matchesSelector=function(e,t){if((e.ownerDocument||e)!==m&&u(e),_.matchesSelector&&f&&!C[t+" "]&&(!P||!P.test(t))&&(!h||!h.test(t)))try{var i=g.call(e,t);if(i||_.disconnectedMatch||e.document&&11!==e.document.nodeType)return i}catch(e){C(t,!0)}return ce(t,m,null,[e]).length>0},ce.contains=function(e,t){return(e.ownerDocument||e)!==m&&u(e),E(e,t)},ce.attr=function(e,t){(e.ownerDocument||e)!==m&&u(e);var r=i.attrHandle[t.toLowerCase()],n=r&&R.call(i.attrHandle,t.toLowerCase())?r(e,t,!f):void 0;return void 0!==n?n:_.attributes||!f?e.getAttribute(t):(n=e.getAttributeNode(t))&&n.specified?n.value:null},ce.escape=function(e){return(e+"").replace(ie,re)},ce.error=function(e){throw new Error("Syntax error, unrecognized expression: "+e)},ce.uniqueSort=function(e){var t,i=[],r=0,n=0;if(d=!_.detectDuplicates,s=!_.sortStable&&e.slice(0),e.sort(M),d){for(;t=e[n++];)t===e[n]&&(r=i.push(n));for(;r--;)e.splice(i[r],1)}return s=null,e},r=ce.getText=function(e){var t,_="",i=0,n=e.nodeType;if(n){if(1===n||9===n||11===n){if("string"==typeof e.textContent)return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)_+=r(e)}else if(3===n||4===n)return e.nodeValue}else for(;t=e[i++];)_+=r(t);return _},(i=ce.selectors={cacheLength:50,createPseudo:le,match:J,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,_e),e[3]=(e[3]||e[4]||e[5]||"").replace(te,_e),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||ce.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&ce.error(e[0]),e},PSEUDO:function(e){var t,_=!e[6]&&e[2];return J.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":_&&V.test(_)&&(t=o(_,!0))&&(t=_.indexOf(")",_.length-t)-_.length)&&(e[0]=e[0].slice(0,t),e[2]=_.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,_e).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=S[e+" "];return t||(t=new RegExp("(^|"+k+")"+e+"("+k+"|$)"))&&S(e,(function(e){return t.test("string"==typeof e.className&&e.className||void 0!==e.getAttribute&&e.getAttribute("class")||"")}))},ATTR:function(e,t,_){return function(i){var r=ce.attr(i,e);return null==r?"!="===t:!t||(r+="","="===t?r===_:"!="===t?r!==_:"^="===t?_&&0===r.indexOf(_):"*="===t?_&&r.indexOf(_)>-1:"$="===t?_&&r.slice(-_.length)===_:"~="===t?(" "+r.replace(q," ")+" ").indexOf(_)>-1:"|="===t&&(r===_||r.slice(0,_.length+1)===_+"-"))}},CHILD:function(e,t,_,i,r){var n="nth"!==e.slice(0,3),o="last"!==e.slice(-4),c="of-type"===t;return 1===i&&0===r?function(e){return!!e.parentNode}:function(t,_,a){var l,s,d,u,m,p,f=n!==o?"nextSibling":"previousSibling",h=t.parentNode,P=c&&t.nodeName.toLowerCase(),g=!a&&!c,E=!1;if(h){if(n){for(;f;){for(u=t;u=u[f];)if(c?u.nodeName.toLowerCase()===P:1===u.nodeType)return!1;p=f="only"===e&&!p&&"nextSibling"}return!0}if(p=[o?h.firstChild:h.lastChild],o&&g){for(E=(m=(l=(s=(d=(u=h)[b]||(u[b]={}))[u.uniqueID]||(d[u.uniqueID]={}))[e]||[])[0]===N&&l[1])&&l[2],u=m&&h.childNodes[m];u=++m&&u&&u[f]||(E=m=0)||p.pop();)if(1===u.nodeType&&++E&&u===t){s[e]=[N,m,E];break}}else if(g&&(E=m=(l=(s=(d=(u=t)[b]||(u[b]={}))[u.uniqueID]||(d[u.uniqueID]={}))[e]||[])[0]===N&&l[1]),!1===E)for(;(u=++m&&u&&u[f]||(E=m=0)||p.pop())&&((c?u.nodeName.toLowerCase()!==P:1!==u.nodeType)||!++E||(g&&((s=(d=u[b]||(u[b]={}))[u.uniqueID]||(d[u.uniqueID]={}))[e]=[N,E]),u!==t)););return(E-=r)===i||E%i==0&&E/i>=0}}},PSEUDO:function(e,t){var _,r=i.pseudos[e]||i.setFilters[e.toLowerCase()]||ce.error("unsupported pseudo: "+e);return r[b]?r(t):r.length>1?(_=[e,e,"",t],i.setFilters.hasOwnProperty(e.toLowerCase())?le((function(e,_){for(var i,n=r(e,t),o=n.length;o--;)e[i=D(e,n[o])]=!(_[i]=n[o])})):function(e){return r(e,0,_)}):r}},pseudos:{not:le((function(e){var t=[],_=[],i=c(e.replace(H,"$1"));return i[b]?le((function(e,t,_,r){for(var n,o=i(e,null,r,[]),c=e.length;c--;)(n=o[c])&&(e[c]=!(t[c]=n))})):function(e,r,n){return t[0]=e,i(t,null,n,_),t[0]=null,!_.pop()}})),has:le((function(e){return function(t){return ce(e,t).length>0}})),contains:le((function(e){return e=e.replace(te,_e),function(t){return(t.textContent||r(t)).indexOf(e)>-1}})),lang:le((function(e){return K.test(e||"")||ce.error("unsupported lang: "+e),e=e.replace(te,_e).toLowerCase(),function(t){var _;do{if(_=f?t.lang:t.getAttribute("xml:lang")||t.getAttribute("lang"))return(_=_.toLowerCase())===e||0===_.indexOf(e+"-")}while((t=t.parentNode)&&1===t.nodeType);return!1}})),target:function(t){var _=e.location&&e.location.hash;return _&&_.slice(1)===t.id},root:function(e){return e===p},focus:function(e){return e===m.activeElement&&(!m.hasFocus||m.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},enabled:fe(!1),disabled:fe(!0),checked:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&!!e.checked||"option"===t&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,!0===e.selected},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeType<6)return!1;return!0},parent:function(e){return!i.pseudos.empty(e)},header:function(e){return X.test(e.nodeName)},input:function(e){return j.test(e.nodeName)},button:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&"button"===e.type||"button"===t},text:function(e){var t;return"input"===e.nodeName.toLowerCase()&&"text"===e.type&&(null==(t=e.getAttribute("type"))||"text"===t.toLowerCase())},first:he((function(){return[0]})),last:he((function(e,t){return[t-1]})),eq:he((function(e,t,_){return[_<0?_+t:_]})),even:he((function(e,t){for(var _=0;_t?t:_;--i>=0;)e.push(i);return e})),gt:he((function(e,t,_){for(var i=_<0?_+t:_;++i1?function(t,_,i){for(var r=e.length;r--;)if(!e[r](t,_,i))return!1;return!0}:e[0]}function Ne(e,t,_,i,r){for(var n,o=[],c=0,a=e.length,l=null!=t;c-1&&(n[l]=!(o[l]=d))}}else P=Ne(P===o?P.splice(p,P.length):P),r?r(null,o,P,a):F.apply(o,P)}))}function Se(e){for(var t,_,r,n=e.length,o=i.relative[e[0].type],c=o||i.relative[" "],a=o?1:0,s=be((function(e){return e===t}),c,!0),d=be((function(e){return D(t,e)>-1}),c,!0),u=[function(e,_,i){var r=!o&&(i||_!==l)||((t=_).nodeType?s(e,_,i):d(e,_,i));return t=null,r}];a1&&Ae(u),a>1&&Ee(e.slice(0,a-1).concat({value:" "===e[a-2].type?"*":""})).replace(H,"$1"),_,a0,r=e.length>0,n=function(n,o,c,a,s){var d,p,h,P=0,g="0",E=n&&[],b=[],A=l,x=n||r&&i.find.TAG("*",s),S=N+=null==A?1:Math.random()||.1,y=x.length;for(s&&(l=o===m||o||s);g!==y&&null!=(d=x[g]);g++){if(r&&d){for(p=0,o||d.ownerDocument===m||(u(d),c=!f);h=e[p++];)if(h(d,o||m,c)){a.push(d);break}s&&(N=S)}_&&((d=!h&&d)&&P--,n&&E.push(d))}if(P+=g,_&&g!==P){for(p=0;h=t[p++];)h(E,b,o,c);if(n){if(P>0)for(;g--;)E[g]||b[g]||(b[g]=T.call(a));b=Ne(b)}F.apply(a,b),s&&!n&&b.length>0&&P+t.length>1&&ce.uniqueSort(a)}return s&&(N=S,l=A),E};return _?le(n):n}(n,r))).selector=e}return c},a=ce.select=function(e,t,_,r){var n,a,l,s,d,u="function"==typeof e&&e,m=!r&&o(e=u.selector||e);if(_=_||[],1===m.length){if((a=m[0]=m[0].slice(0)).length>2&&"ID"===(l=a[0]).type&&9===t.nodeType&&f&&i.relative[a[1].type]){if(!(t=(i.find.ID(l.matches[0].replace(te,_e),t)||[])[0]))return _;u&&(t=t.parentNode),e=e.slice(a.shift().value.length)}for(n=J.needsContext.test(e)?0:a.length;n--&&(l=a[n],!i.relative[s=l.type]);)if((d=i.find[s])&&(r=d(l.matches[0].replace(te,_e),ee.test(a[0].type)&&Pe(t.parentNode)||t))){if(a.splice(n,1),!(e=r.length&&Ee(a)))return F.apply(_,r),_;break}}return(u||c(e,m))(r,t,!f,_,!t||ee.test(e)&&Pe(t.parentNode)||t),_},_.sortStable=b.split("").sort(M).join("")===b,_.detectDuplicates=!!d,u(),_.sortDetached=se((function(e){return 1&e.compareDocumentPosition(m.createElement("fieldset"))})),se((function(e){return e.innerHTML="","#"===e.firstChild.getAttribute("href")}))||de("type|href|height|width",(function(e,t,_){if(!_)return e.getAttribute(t,"type"===t.toLowerCase()?1:2)})),_.attributes&&se((function(e){return e.innerHTML="",e.firstChild.setAttribute("value",""),""===e.firstChild.getAttribute("value")}))||de("value",(function(e,t,_){if(!_&&"input"===e.nodeName.toLowerCase())return e.defaultValue})),se((function(e){return null==e.getAttribute("disabled")}))||de(L,(function(e,t,_){var i;if(!_)return!0===e[t]?t.toLowerCase():(i=e.getAttributeNode(t))&&i.specified?i.value:null})),ce}(_);x.find=v,x.expr=v.selectors,x.expr[":"]=x.expr.pseudos,x.uniqueSort=x.unique=v.uniqueSort,x.text=v.getText,x.isXMLDoc=v.isXML,x.contains=v.contains,x.escapeSelector=v.escape;var C=function(e,t,_){for(var i=[],r=void 0!==_;(e=e[t])&&9!==e.nodeType;)if(1===e.nodeType){if(r&&x(e).is(_))break;i.push(e)}return i},M=function(e,t){for(var _=[];e;e=e.nextSibling)1===e.nodeType&&e!==t&&_.push(e);return _},R=x.expr.match.needsContext;function I(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()}var T=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function B(e,t,_){return g(t)?x.grep(e,(function(e,i){return!!t.call(e,i,e)!==_})):t.nodeType?x.grep(e,(function(e){return e===t!==_})):"string"!=typeof t?x.grep(e,(function(e){return d.call(t,e)>-1!==_})):x.filter(t,e,_)}x.filter=function(e,t,_){var i=t[0];return _&&(e=":not("+e+")"),1===t.length&&1===i.nodeType?x.find.matchesSelector(i,e)?[i]:[]:x.find.matches(e,x.grep(t,(function(e){return 1===e.nodeType})))},x.fn.extend({find:function(e){var t,_,i=this.length,r=this;if("string"!=typeof e)return this.pushStack(x(e).filter((function(){for(t=0;t1?x.uniqueSort(_):_},filter:function(e){return this.pushStack(B(this,e||[],!1))},not:function(e){return this.pushStack(B(this,e||[],!0))},is:function(e){return!!B(this,"string"==typeof e&&R.test(e)?x(e):e||[],!1).length}});var F,w=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/;(x.fn.init=function(e,t,_){var i,r;if(!e)return this;if(_=_||F,"string"==typeof e){if(!(i="<"===e[0]&&">"===e[e.length-1]&&e.length>=3?[null,e,null]:w.exec(e))||!i[1]&&t)return!t||t.jquery?(t||_).find(e):this.constructor(t).find(e);if(i[1]){if(t=t instanceof x?t[0]:t,x.merge(this,x.parseHTML(i[1],t&&t.nodeType?t.ownerDocument||t:o,!0)),T.test(i[1])&&x.isPlainObject(t))for(i in t)g(this[i])?this[i](t[i]):this.attr(i,t[i]);return this}return(r=o.getElementById(i[2]))&&(this[0]=r,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):g(e)?void 0!==_.ready?_.ready(e):e(x):x.makeArray(e,this)}).prototype=x.fn,F=x(o);var D=/^(?:parents|prev(?:Until|All))/,L={children:!0,contents:!0,next:!0,prev:!0};function k(e,t){for(;(e=e[t])&&1!==e.nodeType;);return e}x.fn.extend({has:function(e){var t=x(e,this),_=t.length;return this.filter((function(){for(var e=0;e<_;e++)if(x.contains(this,t[e]))return!0}))},closest:function(e,t){var _,i=0,r=this.length,n=[],o="string"!=typeof e&&x(e);if(!R.test(e))for(;i-1:1===_.nodeType&&x.find.matchesSelector(_,e))){n.push(_);break}return this.pushStack(n.length>1?x.uniqueSort(n):n)},index:function(e){return e?"string"==typeof e?d.call(x(e),this[0]):d.call(this,e.jquery?e[0]:e):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){return this.pushStack(x.uniqueSort(x.merge(this.get(),x(e,t))))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}}),x.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return C(e,"parentNode")},parentsUntil:function(e,t,_){return C(e,"parentNode",_)},next:function(e){return k(e,"nextSibling")},prev:function(e){return k(e,"previousSibling")},nextAll:function(e){return C(e,"nextSibling")},prevAll:function(e){return C(e,"previousSibling")},nextUntil:function(e,t,_){return C(e,"nextSibling",_)},prevUntil:function(e,t,_){return C(e,"previousSibling",_)},siblings:function(e){return M((e.parentNode||{}).firstChild,e)},children:function(e){return M(e.firstChild)},contents:function(e){return void 0!==e.contentDocument?e.contentDocument:(I(e,"template")&&(e=e.content||e),x.merge([],e.childNodes))}},(function(e,t){x.fn[e]=function(_,i){var r=x.map(this,t,_);return"Until"!==e.slice(-5)&&(i=_),i&&"string"==typeof i&&(r=x.filter(i,r)),this.length>1&&(L[e]||x.uniqueSort(r),D.test(e)&&r.reverse()),this.pushStack(r)}}));var Q=/[^\x20\t\r\n\f]+/g;function O(e){return e}function U(e){throw e}function q(e,t,_,i){var r;try{e&&g(r=e.promise)?r.call(e).done(t).fail(_):e&&g(r=e.then)?r.call(e,t,_):t.apply(void 0,[e].slice(i))}catch(e){_.apply(void 0,[e])}}x.Callbacks=function(e){e="string"==typeof e?function(e){var t={};return x.each(e.match(Q)||[],(function(e,_){t[_]=!0})),t}(e):x.extend({},e);var t,_,i,r,n=[],o=[],c=-1,a=function(){for(r=r||e.once,i=t=!0;o.length;c=-1)for(_=o.shift();++c-1;)n.splice(_,1),_<=c&&c--})),this},has:function(e){return e?x.inArray(e,n)>-1:n.length>0},empty:function(){return n&&(n=[]),this},disable:function(){return r=o=[],n=_="",this},disabled:function(){return!n},lock:function(){return r=o=[],_||t||(n=_=""),this},locked:function(){return!!r},fireWith:function(e,_){return r||(_=[e,(_=_||[]).slice?_.slice():_],o.push(_),t||a()),this},fire:function(){return l.fireWith(this,arguments),this},fired:function(){return!!i}};return l},x.extend({Deferred:function(e){var t=[["notify","progress",x.Callbacks("memory"),x.Callbacks("memory"),2],["resolve","done",x.Callbacks("once memory"),x.Callbacks("once memory"),0,"resolved"],["reject","fail",x.Callbacks("once memory"),x.Callbacks("once memory"),1,"rejected"]],i="pending",r={state:function(){return i},always:function(){return n.done(arguments).fail(arguments),this},catch:function(e){return r.then(null,e)},pipe:function(){var e=arguments;return x.Deferred((function(_){x.each(t,(function(t,i){var r=g(e[i[4]])&&e[i[4]];n[i[1]]((function(){var e=r&&r.apply(this,arguments);e&&g(e.promise)?e.promise().progress(_.notify).done(_.resolve).fail(_.reject):_[i[0]+"With"](this,r?[e]:arguments)}))})),e=null})).promise()},then:function(e,i,r){var n=0;function o(e,t,i,r){return function(){var c=this,a=arguments,l=function(){var _,l;if(!(e=n&&(i!==U&&(c=void 0,a=[_]),t.rejectWith(c,a))}};e?s():(x.Deferred.getStackHook&&(s.stackTrace=x.Deferred.getStackHook()),_.setTimeout(s))}}return x.Deferred((function(_){t[0][3].add(o(0,_,g(r)?r:O,_.notifyWith)),t[1][3].add(o(0,_,g(e)?e:O)),t[2][3].add(o(0,_,g(i)?i:U))})).promise()},promise:function(e){return null!=e?x.extend(e,r):r}},n={};return x.each(t,(function(e,_){var o=_[2],c=_[5];r[_[1]]=o.add,c&&o.add((function(){i=c}),t[3-e][2].disable,t[3-e][3].disable,t[0][2].lock,t[0][3].lock),o.add(_[3].fire),n[_[0]]=function(){return n[_[0]+"With"](this===n?void 0:this,arguments),this},n[_[0]+"With"]=o.fireWith})),r.promise(n),e&&e.call(n,n),n},when:function(e){var t=arguments.length,_=t,i=Array(_),r=a.call(arguments),n=x.Deferred(),o=function(e){return function(_){i[e]=this,r[e]=arguments.length>1?a.call(arguments):_,--t||n.resolveWith(i,r)}};if(t<=1&&(q(e,n.done(o(_)).resolve,n.reject,!t),"pending"===n.state()||g(r[_]&&r[_].then)))return n.then();for(;_--;)q(r[_],o(_),n.reject);return n.promise()}});var H=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;x.Deferred.exceptionHook=function(e,t){_.console&&_.console.warn&&e&&H.test(e.name)&&_.console.warn("jQuery.Deferred exception: "+e.message,e.stack,t)},x.readyException=function(e){_.setTimeout((function(){throw e}))};var G=x.Deferred();function z(){o.removeEventListener("DOMContentLoaded",z),_.removeEventListener("load",z),x.ready()}x.fn.ready=function(e){return G.then(e).catch((function(e){x.readyException(e)})),this},x.extend({isReady:!1,readyWait:1,ready:function(e){(!0===e?--x.readyWait:x.isReady)||(x.isReady=!0,!0!==e&&--x.readyWait>0||G.resolveWith(o,[x]))}}),x.ready.then=G.then,"complete"===o.readyState||"loading"!==o.readyState&&!o.documentElement.doScroll?_.setTimeout(x.ready):(o.addEventListener("DOMContentLoaded",z),_.addEventListener("load",z));var W=function(e,t,_,i,r,n,o){var c=0,a=e.length,l=null==_;if("object"===N(_))for(c in r=!0,_)W(e,t,c,_[c],!0,n,o);else if(void 0!==i&&(r=!0,g(i)||(o=!0),l&&(o?(t.call(e,i),t=null):(l=t,t=function(e,t,_){return l.call(x(e),_)})),t))for(;c1,null,!0)},removeData:function(e){return this.each((function(){Z.remove(this,e)}))}}),x.extend({queue:function(e,t,_){var i;if(e)return t=(t||"fx")+"queue",i=$.get(e,t),_&&(!i||Array.isArray(_)?i=$.access(e,t,x.makeArray(_)):i.push(_)),i||[]},dequeue:function(e,t){t=t||"fx";var _=x.queue(e,t),i=_.length,r=_.shift(),n=x._queueHooks(e,t);"inprogress"===r&&(r=_.shift(),i--),r&&("fx"===t&&_.unshift("inprogress"),delete n.stop,r.call(e,(function(){x.dequeue(e,t)}),n)),!i&&n&&n.empty.fire()},_queueHooks:function(e,t){var _=t+"queueHooks";return $.get(e,_)||$.access(e,_,{empty:x.Callbacks("once memory").add((function(){$.remove(e,[t+"queue",_])}))})}}),x.fn.extend({queue:function(e,t){var _=2;return"string"!=typeof e&&(t=e,e="fx",_--),arguments.length<_?x.queue(this[0],e):void 0===t?this:this.each((function(){var _=x.queue(this,e,t);x._queueHooks(this,e),"fx"===e&&"inprogress"!==_[0]&&x.dequeue(this,e)}))},dequeue:function(e){return this.each((function(){x.dequeue(this,e)}))},clearQueue:function(e){return this.queue(e||"fx",[])},promise:function(e,t){var _,i=1,r=x.Deferred(),n=this,o=this.length,c=function(){--i||r.resolveWith(n,[n])};for("string"!=typeof e&&(t=e,e=void 0),e=e||"fx";o--;)(_=$.get(n[o],e+"queueHooks"))&&_.empty&&(i++,_.empty.add(c));return c(),r.promise(t)}});var ie=/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source,re=new RegExp("^(?:([+-])=|)("+ie+")([a-z%]*)$","i"),ne=["Top","Right","Bottom","Left"],oe=o.documentElement,ce=function(e){return x.contains(e.ownerDocument,e)},ae={composed:!0};oe.getRootNode&&(ce=function(e){return x.contains(e.ownerDocument,e)||e.getRootNode(ae)===e.ownerDocument});var le=function(e,t){return"none"===(e=t||e).style.display||""===e.style.display&&ce(e)&&"none"===x.css(e,"display")},se=function(e,t,_,i){var r,n,o={};for(n in t)o[n]=e.style[n],e.style[n]=t[n];for(n in r=_.apply(e,i||[]),t)e.style[n]=o[n];return r};function de(e,t,_,i){var r,n,o=20,c=i?function(){return i.cur()}:function(){return x.css(e,t,"")},a=c(),l=_&&_[3]||(x.cssNumber[t]?"":"px"),s=e.nodeType&&(x.cssNumber[t]||"px"!==l&&+a)&&re.exec(x.css(e,t));if(s&&s[3]!==l){for(a/=2,l=l||s[3],s=+a||1;o--;)x.style(e,t,s+l),(1-n)*(1-(n=c()/a||.5))<=0&&(o=0),s/=n;s*=2,x.style(e,t,s+l),_=_||[]}return _&&(s=+s||+a||0,r=_[1]?s+(_[1]+1)*_[2]:+_[2],i&&(i.unit=l,i.start=s,i.end=r)),r}var ue={};function me(e){var t,_=e.ownerDocument,i=e.nodeName,r=ue[i];return r||(t=_.body.appendChild(_.createElement(i)),r=x.css(t,"display"),t.parentNode.removeChild(t),"none"===r&&(r="block"),ue[i]=r,r)}function pe(e,t){for(var _,i,r=[],n=0,o=e.length;n\x20\t\r\n\f]*)/i,Pe=/^$|^module$|\/(?:java|ecma)script/i,ge={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function Ee(e,t){var _;return _=void 0!==e.getElementsByTagName?e.getElementsByTagName(t||"*"):void 0!==e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&I(e,t)?x.merge([e],_):_}function be(e,t){for(var _=0,i=e.length;_-1)r&&r.push(n);else if(l=ce(n),o=Ee(d.appendChild(n),"script"),l&&be(o),_)for(s=0;n=o[s++];)Pe.test(n.type||"")&&_.push(n);return d}Ae=o.createDocumentFragment().appendChild(o.createElement("div")),(Ne=o.createElement("input")).setAttribute("type","radio"),Ne.setAttribute("checked","checked"),Ne.setAttribute("name","t"),Ae.appendChild(Ne),P.checkClone=Ae.cloneNode(!0).cloneNode(!0).lastChild.checked,Ae.innerHTML="",P.noCloneChecked=!!Ae.cloneNode(!0).lastChild.defaultValue;var ye=/^key/,ve=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Ce=/^([^.]*)(?:\.(.+)|)/;function Me(){return!0}function Re(){return!1}function Ie(e,t){return e===function(){try{return o.activeElement}catch(e){}}()==("focus"===t)}function Te(e,t,_,i,r,n){var o,c;if("object"==typeof t){for(c in"string"!=typeof _&&(i=i||_,_=void 0),t)Te(e,c,_,i,t[c],n);return e}if(null==i&&null==r?(r=_,i=_=void 0):null==r&&("string"==typeof _?(r=i,i=void 0):(r=i,i=_,_=void 0)),!1===r)r=Re;else if(!r)return e;return 1===n&&(o=r,(r=function(e){return x().off(e),o.apply(this,arguments)}).guid=o.guid||(o.guid=x.guid++)),e.each((function(){x.event.add(this,t,r,i,_)}))}function Be(e,t,_){_?($.set(e,t,!1),x.event.add(e,t,{namespace:!1,handler:function(e){var i,r,n=$.get(this,t);if(1&e.isTrigger&&this[t]){if(n.length)(x.event.special[t]||{}).delegateType&&e.stopPropagation();else if(n=a.call(arguments),$.set(this,t,n),i=_(this,t),this[t](),n!==(r=$.get(this,t))||i?$.set(this,t,!1):r={},n!==r)return e.stopImmediatePropagation(),e.preventDefault(),r.value}else n.length&&($.set(this,t,{value:x.event.trigger(x.extend(n[0],x.Event.prototype),n.slice(1),this)}),e.stopImmediatePropagation())}})):void 0===$.get(e,t)&&x.event.add(e,t,Me)}x.event={global:{},add:function(e,t,_,i,r){var n,o,c,a,l,s,d,u,m,p,f,h=$.get(e);if(h)for(_.handler&&(_=(n=_).handler,r=n.selector),r&&x.find.matchesSelector(oe,r),_.guid||(_.guid=x.guid++),(a=h.events)||(a=h.events={}),(o=h.handle)||(o=h.handle=function(t){return void 0!==x&&x.event.triggered!==t.type?x.event.dispatch.apply(e,arguments):void 0}),l=(t=(t||"").match(Q)||[""]).length;l--;)m=f=(c=Ce.exec(t[l])||[])[1],p=(c[2]||"").split(".").sort(),m&&(d=x.event.special[m]||{},m=(r?d.delegateType:d.bindType)||m,d=x.event.special[m]||{},s=x.extend({type:m,origType:f,data:i,handler:_,guid:_.guid,selector:r,needsContext:r&&x.expr.match.needsContext.test(r),namespace:p.join(".")},n),(u=a[m])||((u=a[m]=[]).delegateCount=0,d.setup&&!1!==d.setup.call(e,i,p,o)||e.addEventListener&&e.addEventListener(m,o)),d.add&&(d.add.call(e,s),s.handler.guid||(s.handler.guid=_.guid)),r?u.splice(u.delegateCount++,0,s):u.push(s),x.event.global[m]=!0)},remove:function(e,t,_,i,r){var n,o,c,a,l,s,d,u,m,p,f,h=$.hasData(e)&&$.get(e);if(h&&(a=h.events)){for(l=(t=(t||"").match(Q)||[""]).length;l--;)if(m=f=(c=Ce.exec(t[l])||[])[1],p=(c[2]||"").split(".").sort(),m){for(d=x.event.special[m]||{},u=a[m=(i?d.delegateType:d.bindType)||m]||[],c=c[2]&&new RegExp("(^|\\.)"+p.join("\\.(?:.*\\.|)")+"(\\.|$)"),o=n=u.length;n--;)s=u[n],!r&&f!==s.origType||_&&_.guid!==s.guid||c&&!c.test(s.namespace)||i&&i!==s.selector&&("**"!==i||!s.selector)||(u.splice(n,1),s.selector&&u.delegateCount--,d.remove&&d.remove.call(e,s));o&&!u.length&&(d.teardown&&!1!==d.teardown.call(e,p,h.handle)||x.removeEvent(e,m,h.handle),delete a[m])}else for(m in a)x.event.remove(e,m+t[l],_,i,!0);x.isEmptyObject(a)&&$.remove(e,"handle events")}},dispatch:function(e){var t,_,i,r,n,o,c=x.event.fix(e),a=new Array(arguments.length),l=($.get(this,"events")||{})[c.type]||[],s=x.event.special[c.type]||{};for(a[0]=c,t=1;t=1))for(;l!==this;l=l.parentNode||this)if(1===l.nodeType&&("click"!==e.type||!0!==l.disabled)){for(n=[],o={},_=0;_-1:x.find(r,this,null,[l]).length),o[r]&&n.push(i);n.length&&c.push({elem:l,handlers:n})}return l=this,a\x20\t\r\n\f]*)[^>]*)\/>/gi,we=/\s*$/g;function ke(e,t){return I(e,"table")&&I(11!==t.nodeType?t:t.firstChild,"tr")&&x(e).children("tbody")[0]||e}function Qe(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function Oe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Ue(e,t){var _,i,r,n,o,c,a,l;if(1===t.nodeType){if($.hasData(e)&&(n=$.access(e),o=$.set(t,n),l=n.events))for(r in delete o.handle,o.events={},l)for(_=0,i=l[r].length;_1&&"string"==typeof p&&!P.checkClone&&De.test(p))return e.each((function(r){var n=e.eq(r);f&&(t[0]=p.call(this,r,n.html())),He(n,t,_,i)}));if(u&&(n=(r=Se(t,e[0].ownerDocument,!1,e,i)).firstChild,1===r.childNodes.length&&(r=n),n||i)){for(c=(o=x.map(Ee(r,"script"),Qe)).length;d")},clone:function(e,t,_){var i,r,n,o,c=e.cloneNode(!0),a=ce(e);if(!(P.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||x.isXMLDoc(e)))for(o=Ee(c),i=0,r=(n=Ee(e)).length;i0&&be(o,!a&&Ee(e,"script")),c},cleanData:function(e){for(var t,_,i,r=x.event.special,n=0;void 0!==(_=e[n]);n++)if(j(_)){if(t=_[$.expando]){if(t.events)for(i in t.events)r[i]?x.event.remove(_,i):x.removeEvent(_,i,t.handle);_[$.expando]=void 0}_[Z.expando]&&(_[Z.expando]=void 0)}}}),x.fn.extend({detach:function(e){return Ge(this,e,!0)},remove:function(e){return Ge(this,e)},text:function(e){return W(this,(function(e){return void 0===e?x.text(this):this.empty().each((function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=e)}))}),null,e,arguments.length)},append:function(){return He(this,arguments,(function(e){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||ke(this,e).appendChild(e)}))},prepend:function(){return He(this,arguments,(function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=ke(this,e);t.insertBefore(e,t.firstChild)}}))},before:function(){return He(this,arguments,(function(e){this.parentNode&&this.parentNode.insertBefore(e,this)}))},after:function(){return He(this,arguments,(function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)}))},empty:function(){for(var e,t=0;null!=(e=this[t]);t++)1===e.nodeType&&(x.cleanData(Ee(e,!1)),e.textContent="");return this},clone:function(e,t){return e=null!=e&&e,t=null==t?e:t,this.map((function(){return x.clone(this,e,t)}))},html:function(e){return W(this,(function(e){var t=this[0]||{},_=0,i=this.length;if(void 0===e&&1===t.nodeType)return t.innerHTML;if("string"==typeof e&&!we.test(e)&&!ge[(he.exec(e)||["",""])[1].toLowerCase()]){e=x.htmlPrefilter(e);try{for(;_=0&&(a+=Math.max(0,Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-n-a-c-.5))||0),a}function nt(e,t,_){var i=We(e),r=(!P.boxSizingReliable()||_)&&"border-box"===x.css(e,"boxSizing",!1,i),n=r,o=Ke(e,t,i),c="offset"+t[0].toUpperCase()+t.slice(1);if(ze.test(o)){if(!_)return o;o="auto"}return(!P.boxSizingReliable()&&r||"auto"===o||!parseFloat(o)&&"inline"===x.css(e,"display",!1,i))&&e.getClientRects().length&&(r="border-box"===x.css(e,"boxSizing",!1,i),(n=c in e)&&(o=e[c])),(o=parseFloat(o)||0)+rt(e,t,_||(r?"border":"content"),n,i,o)+"px"}function ot(e,t,_,i,r){return new ot.prototype.init(e,t,_,i,r)}x.extend({cssHooks:{opacity:{get:function(e,t){if(t){var _=Ke(e,"opacity");return""===_?"1":_}}}},cssNumber:{animationIterationCount:!0,columnCount:!0,fillOpacity:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,gridArea:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnStart:!0,gridRow:!0,gridRowEnd:!0,gridRowStart:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{},style:function(e,t,_,i){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var r,n,o,c=Y(t),a=et.test(t),l=e.style;if(a||(t=$e(c)),o=x.cssHooks[t]||x.cssHooks[c],void 0===_)return o&&"get"in o&&void 0!==(r=o.get(e,!1,i))?r:l[t];"string"===(n=typeof _)&&(r=re.exec(_))&&r[1]&&(_=de(e,t,r),n="number"),null!=_&&_==_&&("number"!==n||a||(_+=r&&r[3]||(x.cssNumber[c]?"":"px")),P.clearCloneStyle||""!==_||0!==t.indexOf("background")||(l[t]="inherit"),o&&"set"in o&&void 0===(_=o.set(e,_,i))||(a?l.setProperty(t,_):l[t]=_))}},css:function(e,t,_,i){var r,n,o,c=Y(t);return et.test(t)||(t=$e(c)),(o=x.cssHooks[t]||x.cssHooks[c])&&"get"in o&&(r=o.get(e,!0,_)),void 0===r&&(r=Ke(e,t,i)),"normal"===r&&t in _t&&(r=_t[t]),""===_||_?(n=parseFloat(r),!0===_||isFinite(n)?n||0:r):r}}),x.each(["height","width"],(function(e,t){x.cssHooks[t]={get:function(e,_,i){if(_)return!Ze.test(x.css(e,"display"))||e.getClientRects().length&&e.getBoundingClientRect().width?nt(e,t,i):se(e,tt,(function(){return nt(e,t,i)}))},set:function(e,_,i){var r,n=We(e),o=!P.scrollboxSize()&&"absolute"===n.position,c=(o||i)&&"border-box"===x.css(e,"boxSizing",!1,n),a=i?rt(e,t,i,c,n):0;return c&&o&&(a-=Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-parseFloat(n[t])-rt(e,t,"border",!1,n)-.5)),a&&(r=re.exec(_))&&"px"!==(r[3]||"px")&&(e.style[t]=_,_=x.css(e,t)),it(0,_,a)}}})),x.cssHooks.marginLeft=Je(P.reliableMarginLeft,(function(e,t){if(t)return(parseFloat(Ke(e,"marginLeft"))||e.getBoundingClientRect().left-se(e,{marginLeft:0},(function(){return e.getBoundingClientRect().left})))+"px"})),x.each({margin:"",padding:"",border:"Width"},(function(e,t){x.cssHooks[e+t]={expand:function(_){for(var i=0,r={},n="string"==typeof _?_.split(" "):[_];i<4;i++)r[e+ne[i]+t]=n[i]||n[i-2]||n[0];return r}},"margin"!==e&&(x.cssHooks[e+t].set=it)})),x.fn.extend({css:function(e,t){return W(this,(function(e,t,_){var i,r,n={},o=0;if(Array.isArray(t)){for(i=We(e),r=t.length;o1)}}),x.Tween=ot,ot.prototype={constructor:ot,init:function(e,t,_,i,r,n){this.elem=e,this.prop=_,this.easing=r||x.easing._default,this.options=t,this.start=this.now=this.cur(),this.end=i,this.unit=n||(x.cssNumber[_]?"":"px")},cur:function(){var e=ot.propHooks[this.prop];return e&&e.get?e.get(this):ot.propHooks._default.get(this)},run:function(e){var t,_=ot.propHooks[this.prop];return this.options.duration?this.pos=t=x.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):this.pos=t=e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),_&&_.set?_.set(this):ot.propHooks._default.set(this),this}},ot.prototype.init.prototype=ot.prototype,ot.propHooks={_default:{get:function(e){var t;return 1!==e.elem.nodeType||null!=e.elem[e.prop]&&null==e.elem.style[e.prop]?e.elem[e.prop]:(t=x.css(e.elem,e.prop,""))&&"auto"!==t?t:0},set:function(e){x.fx.step[e.prop]?x.fx.step[e.prop](e):1!==e.elem.nodeType||!x.cssHooks[e.prop]&&null==e.elem.style[$e(e.prop)]?e.elem[e.prop]=e.now:x.style(e.elem,e.prop,e.now+e.unit)}}},ot.propHooks.scrollTop=ot.propHooks.scrollLeft={set:function(e){e.elem.nodeType&&e.elem.parentNode&&(e.elem[e.prop]=e.now)}},x.easing={linear:function(e){return e},swing:function(e){return.5-Math.cos(e*Math.PI)/2},_default:"swing"},x.fx=ot.prototype.init,x.fx.step={};var ct,at,lt=/^(?:toggle|show|hide)$/,st=/queueHooks$/;function dt(){at&&(!1===o.hidden&&_.requestAnimationFrame?_.requestAnimationFrame(dt):_.setTimeout(dt,x.fx.interval),x.fx.tick())}function ut(){return _.setTimeout((function(){ct=void 0})),ct=Date.now()}function mt(e,t){var _,i=0,r={height:e};for(t=t?1:0;i<4;i+=2-t)r["margin"+(_=ne[i])]=r["padding"+_]=e;return t&&(r.opacity=r.width=e),r}function pt(e,t,_){for(var i,r=(ft.tweeners[t]||[]).concat(ft.tweeners["*"]),n=0,o=r.length;n1)},removeAttr:function(e){return this.each((function(){x.removeAttr(this,e)}))}}),x.extend({attr:function(e,t,_){var i,r,n=e.nodeType;if(3!==n&&8!==n&&2!==n)return void 0===e.getAttribute?x.prop(e,t,_):(1===n&&x.isXMLDoc(e)||(r=x.attrHooks[t.toLowerCase()]||(x.expr.match.bool.test(t)?ht:void 0)),void 0!==_?null===_?void x.removeAttr(e,t):r&&"set"in r&&void 0!==(i=r.set(e,_,t))?i:(e.setAttribute(t,_+""),_):r&&"get"in r&&null!==(i=r.get(e,t))?i:null==(i=x.find.attr(e,t))?void 0:i)},attrHooks:{type:{set:function(e,t){if(!P.radioValue&&"radio"===t&&I(e,"input")){var _=e.value;return e.setAttribute("type",t),_&&(e.value=_),t}}}},removeAttr:function(e,t){var _,i=0,r=t&&t.match(Q);if(r&&1===e.nodeType)for(;_=r[i++];)e.removeAttribute(_)}}),ht={set:function(e,t,_){return!1===t?x.removeAttr(e,_):e.setAttribute(_,_),_}},x.each(x.expr.match.bool.source.match(/\w+/g),(function(e,t){var _=Pt[t]||x.find.attr;Pt[t]=function(e,t,i){var r,n,o=t.toLowerCase();return i||(n=Pt[o],Pt[o]=r,r=null!=_(e,t,i)?o:null,Pt[o]=n),r}}));var gt=/^(?:input|select|textarea|button)$/i,Et=/^(?:a|area)$/i;function bt(e){return(e.match(Q)||[]).join(" ")}function At(e){return e.getAttribute&&e.getAttribute("class")||""}function Nt(e){return Array.isArray(e)?e:"string"==typeof e&&e.match(Q)||[]}x.fn.extend({prop:function(e,t){return W(this,x.prop,e,t,arguments.length>1)},removeProp:function(e){return this.each((function(){delete this[x.propFix[e]||e]}))}}),x.extend({prop:function(e,t,_){var i,r,n=e.nodeType;if(3!==n&&8!==n&&2!==n)return 1===n&&x.isXMLDoc(e)||(t=x.propFix[t]||t,r=x.propHooks[t]),void 0!==_?r&&"set"in r&&void 0!==(i=r.set(e,_,t))?i:e[t]=_:r&&"get"in r&&null!==(i=r.get(e,t))?i:e[t]},propHooks:{tabIndex:{get:function(e){var t=x.find.attr(e,"tabindex");return t?parseInt(t,10):gt.test(e.nodeName)||Et.test(e.nodeName)&&e.href?0:-1}}},propFix:{for:"htmlFor",class:"className"}}),P.optSelected||(x.propHooks.selected={get:function(e){var t=e.parentNode;return t&&t.parentNode&&t.parentNode.selectedIndex,null},set:function(e){var t=e.parentNode;t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex)}}),x.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],(function(){x.propFix[this.toLowerCase()]=this})),x.fn.extend({addClass:function(e){var t,_,i,r,n,o,c,a=0;if(g(e))return this.each((function(t){x(this).addClass(e.call(this,t,At(this)))}));if((t=Nt(e)).length)for(;_=this[a++];)if(r=At(_),i=1===_.nodeType&&" "+bt(r)+" "){for(o=0;n=t[o++];)i.indexOf(" "+n+" ")<0&&(i+=n+" ");r!==(c=bt(i))&&_.setAttribute("class",c)}return this},removeClass:function(e){var t,_,i,r,n,o,c,a=0;if(g(e))return this.each((function(t){x(this).removeClass(e.call(this,t,At(this)))}));if(!arguments.length)return this.attr("class","");if((t=Nt(e)).length)for(;_=this[a++];)if(r=At(_),i=1===_.nodeType&&" "+bt(r)+" "){for(o=0;n=t[o++];)for(;i.indexOf(" "+n+" ")>-1;)i=i.replace(" "+n+" "," ");r!==(c=bt(i))&&_.setAttribute("class",c)}return this},toggleClass:function(e,t){var _=typeof e,i="string"===_||Array.isArray(e);return"boolean"==typeof t&&i?t?this.addClass(e):this.removeClass(e):g(e)?this.each((function(_){x(this).toggleClass(e.call(this,_,At(this),t),t)})):this.each((function(){var t,r,n,o;if(i)for(r=0,n=x(this),o=Nt(e);t=o[r++];)n.hasClass(t)?n.removeClass(t):n.addClass(t);else void 0!==e&&"boolean"!==_||((t=At(this))&&$.set(this,"__className__",t),this.setAttribute&&this.setAttribute("class",t||!1===e?"":$.get(this,"__className__")||""))}))},hasClass:function(e){var t,_,i=0;for(t=" "+e+" ";_=this[i++];)if(1===_.nodeType&&(" "+bt(At(_))+" ").indexOf(t)>-1)return!0;return!1}});var xt=/\r/g;x.fn.extend({val:function(e){var t,_,i,r=this[0];return arguments.length?(i=g(e),this.each((function(_){var r;1===this.nodeType&&(null==(r=i?e.call(this,_,x(this).val()):e)?r="":"number"==typeof r?r+="":Array.isArray(r)&&(r=x.map(r,(function(e){return null==e?"":e+""}))),(t=x.valHooks[this.type]||x.valHooks[this.nodeName.toLowerCase()])&&"set"in t&&void 0!==t.set(this,r,"value")||(this.value=r))}))):r?(t=x.valHooks[r.type]||x.valHooks[r.nodeName.toLowerCase()])&&"get"in t&&void 0!==(_=t.get(r,"value"))?_:"string"==typeof(_=r.value)?_.replace(xt,""):null==_?"":_:void 0}}),x.extend({valHooks:{option:{get:function(e){var t=x.find.attr(e,"value");return null!=t?t:bt(x.text(e))}},select:{get:function(e){var t,_,i,r=e.options,n=e.selectedIndex,o="select-one"===e.type,c=o?null:[],a=o?n+1:r.length;for(i=n<0?a:o?n:0;i-1)&&(_=!0);return _||(e.selectedIndex=-1),n}}}}),x.each(["radio","checkbox"],(function(){x.valHooks[this]={set:function(e,t){if(Array.isArray(t))return e.checked=x.inArray(x(e).val(),t)>-1}},P.checkOn||(x.valHooks[this].get=function(e){return null===e.getAttribute("value")?"on":e.value})})),P.focusin="onfocusin"in _;var St=/^(?:focusinfocus|focusoutblur)$/,yt=function(e){e.stopPropagation()};x.extend(x.event,{trigger:function(e,t,i,r){var n,c,a,l,s,d,u,m,f=[i||o],h=p.call(e,"type")?e.type:e,P=p.call(e,"namespace")?e.namespace.split("."):[];if(c=m=a=i=i||o,3!==i.nodeType&&8!==i.nodeType&&!St.test(h+x.event.triggered)&&(h.indexOf(".")>-1&&(P=h.split("."),h=P.shift(),P.sort()),s=h.indexOf(":")<0&&"on"+h,(e=e[x.expando]?e:new x.Event(h,"object"==typeof e&&e)).isTrigger=r?2:3,e.namespace=P.join("."),e.rnamespace=e.namespace?new RegExp("(^|\\.)"+P.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,e.result=void 0,e.target||(e.target=i),t=null==t?[e]:x.makeArray(t,[e]),u=x.event.special[h]||{},r||!u.trigger||!1!==u.trigger.apply(i,t))){if(!r&&!u.noBubble&&!E(i)){for(l=u.delegateType||h,St.test(l+h)||(c=c.parentNode);c;c=c.parentNode)f.push(c),a=c;a===(i.ownerDocument||o)&&f.push(a.defaultView||a.parentWindow||_)}for(n=0;(c=f[n++])&&!e.isPropagationStopped();)m=c,e.type=n>1?l:u.bindType||h,(d=($.get(c,"events")||{})[e.type]&&$.get(c,"handle"))&&d.apply(c,t),(d=s&&c[s])&&d.apply&&j(c)&&(e.result=d.apply(c,t),!1===e.result&&e.preventDefault());return e.type=h,r||e.isDefaultPrevented()||u._default&&!1!==u._default.apply(f.pop(),t)||!j(i)||s&&g(i[h])&&!E(i)&&((a=i[s])&&(i[s]=null),x.event.triggered=h,e.isPropagationStopped()&&m.addEventListener(h,yt),i[h](),e.isPropagationStopped()&&m.removeEventListener(h,yt),x.event.triggered=void 0,a&&(i[s]=a)),e.result}},simulate:function(e,t,_){var i=x.extend(new x.Event,_,{type:e,isSimulated:!0});x.event.trigger(i,null,t)}}),x.fn.extend({trigger:function(e,t){return this.each((function(){x.event.trigger(e,t,this)}))},triggerHandler:function(e,t){var _=this[0];if(_)return x.event.trigger(e,t,_,!0)}}),P.focusin||x.each({focus:"focusin",blur:"focusout"},(function(e,t){var _=function(e){x.event.simulate(t,e.target,x.event.fix(e))};x.event.special[t]={setup:function(){var i=this.ownerDocument||this,r=$.access(i,t);r||i.addEventListener(e,_,!0),$.access(i,t,(r||0)+1)},teardown:function(){var i=this.ownerDocument||this,r=$.access(i,t)-1;r?$.access(i,t,r):(i.removeEventListener(e,_,!0),$.remove(i,t))}}}));var vt=_.location,Ct=Date.now(),Mt=/\?/;x.parseXML=function(e){var t;if(!e||"string"!=typeof e)return null;try{t=(new _.DOMParser).parseFromString(e,"text/xml")}catch(e){t=void 0}return t&&!t.getElementsByTagName("parsererror").length||x.error("Invalid XML: "+e),t};var Rt=/\[\]$/,It=/\r?\n/g,Tt=/^(?:submit|button|image|reset|file)$/i,Bt=/^(?:input|select|textarea|keygen)/i;function Ft(e,t,_,i){var r;if(Array.isArray(t))x.each(t,(function(t,r){_||Rt.test(e)?i(e,r):Ft(e+"["+("object"==typeof r&&null!=r?t:"")+"]",r,_,i)}));else if(_||"object"!==N(t))i(e,t);else for(r in t)Ft(e+"["+r+"]",t[r],_,i)}x.param=function(e,t){var _,i=[],r=function(e,t){var _=g(t)?t():t;i[i.length]=encodeURIComponent(e)+"="+encodeURIComponent(null==_?"":_)};if(null==e)return"";if(Array.isArray(e)||e.jquery&&!x.isPlainObject(e))x.each(e,(function(){r(this.name,this.value)}));else for(_ in e)Ft(_,e[_],t,r);return i.join("&")},x.fn.extend({serialize:function(){return x.param(this.serializeArray())},serializeArray:function(){return this.map((function(){var e=x.prop(this,"elements");return e?x.makeArray(e):this})).filter((function(){var e=this.type;return this.name&&!x(this).is(":disabled")&&Bt.test(this.nodeName)&&!Tt.test(e)&&(this.checked||!fe.test(e))})).map((function(e,t){var _=x(this).val();return null==_?null:Array.isArray(_)?x.map(_,(function(e){return{name:t.name,value:e.replace(It,"\r\n")}})):{name:t.name,value:_.replace(It,"\r\n")}})).get()}});var wt=/%20/g,Dt=/#.*$/,Lt=/([?&])_=[^&]*/,kt=/^(.*?):[ \t]*([^\r\n]*)$/gm,Qt=/^(?:GET|HEAD)$/,Ot=/^\/\//,Ut={},qt={},Ht="*/".concat("*"),Gt=o.createElement("a");function zt(e){return function(t,_){"string"!=typeof t&&(_=t,t="*");var i,r=0,n=t.toLowerCase().match(Q)||[];if(g(_))for(;i=n[r++];)"+"===i[0]?(i=i.slice(1)||"*",(e[i]=e[i]||[]).unshift(_)):(e[i]=e[i]||[]).push(_)}}function Wt(e,t,_,i){var r={},n=e===qt;function o(c){var a;return r[c]=!0,x.each(e[c]||[],(function(e,c){var l=c(t,_,i);return"string"!=typeof l||n||r[l]?n?!(a=l):void 0:(t.dataTypes.unshift(l),o(l),!1)})),a}return o(t.dataTypes[0])||!r["*"]&&o("*")}function Vt(e,t){var _,i,r=x.ajaxSettings.flatOptions||{};for(_ in t)void 0!==t[_]&&((r[_]?e:i||(i={}))[_]=t[_]);return i&&x.extend(!0,e,i),e}Gt.href=vt.href,x.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:vt.href,type:"GET",isLocal:/^(?:about|app|app-storage|.+-extension|file|res|widget):$/.test(vt.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":Ht,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":x.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?Vt(Vt(e,x.ajaxSettings),t):Vt(x.ajaxSettings,e)},ajaxPrefilter:zt(Ut),ajaxTransport:zt(qt),ajax:function(e,t){"object"==typeof e&&(t=e,e=void 0),t=t||{};var i,r,n,c,a,l,s,d,u,m,p=x.ajaxSetup({},t),f=p.context||p,h=p.context&&(f.nodeType||f.jquery)?x(f):x.event,P=x.Deferred(),g=x.Callbacks("once memory"),E=p.statusCode||{},b={},A={},N="canceled",S={readyState:0,getResponseHeader:function(e){var t;if(s){if(!c)for(c={};t=kt.exec(n);)c[t[1].toLowerCase()+" "]=(c[t[1].toLowerCase()+" "]||[]).concat(t[2]);t=c[e.toLowerCase()+" "]}return null==t?null:t.join(", ")},getAllResponseHeaders:function(){return s?n:null},setRequestHeader:function(e,t){return null==s&&(e=A[e.toLowerCase()]=A[e.toLowerCase()]||e,b[e]=t),this},overrideMimeType:function(e){return null==s&&(p.mimeType=e),this},statusCode:function(e){var t;if(e)if(s)S.always(e[S.status]);else for(t in e)E[t]=[E[t],e[t]];return this},abort:function(e){var t=e||N;return i&&i.abort(t),y(0,t),this}};if(P.promise(S),p.url=((e||p.url||vt.href)+"").replace(Ot,vt.protocol+"//"),p.type=t.method||t.type||p.method||p.type,p.dataTypes=(p.dataType||"*").toLowerCase().match(Q)||[""],null==p.crossDomain){l=o.createElement("a");try{l.href=p.url,l.href=l.href,p.crossDomain=Gt.protocol+"//"+Gt.host!=l.protocol+"//"+l.host}catch(e){p.crossDomain=!0}}if(p.data&&p.processData&&"string"!=typeof p.data&&(p.data=x.param(p.data,p.traditional)),Wt(Ut,p,t,S),s)return S;for(u in(d=x.event&&p.global)&&0==x.active++&&x.event.trigger("ajaxStart"),p.type=p.type.toUpperCase(),p.hasContent=!Qt.test(p.type),r=p.url.replace(Dt,""),p.hasContent?p.data&&p.processData&&0===(p.contentType||"").indexOf("application/x-www-form-urlencoded")&&(p.data=p.data.replace(wt,"+")):(m=p.url.slice(r.length),p.data&&(p.processData||"string"==typeof p.data)&&(r+=(Mt.test(r)?"&":"?")+p.data,delete p.data),!1===p.cache&&(r=r.replace(Lt,"$1"),m=(Mt.test(r)?"&":"?")+"_="+Ct+++m),p.url=r+m),p.ifModified&&(x.lastModified[r]&&S.setRequestHeader("If-Modified-Since",x.lastModified[r]),x.etag[r]&&S.setRequestHeader("If-None-Match",x.etag[r])),(p.data&&p.hasContent&&!1!==p.contentType||t.contentType)&&S.setRequestHeader("Content-Type",p.contentType),S.setRequestHeader("Accept",p.dataTypes[0]&&p.accepts[p.dataTypes[0]]?p.accepts[p.dataTypes[0]]+("*"!==p.dataTypes[0]?", "+Ht+"; q=0.01":""):p.accepts["*"]),p.headers)S.setRequestHeader(u,p.headers[u]);if(p.beforeSend&&(!1===p.beforeSend.call(f,S,p)||s))return S.abort();if(N="abort",g.add(p.complete),S.done(p.success),S.fail(p.error),i=Wt(qt,p,t,S)){if(S.readyState=1,d&&h.trigger("ajaxSend",[S,p]),s)return S;p.async&&p.timeout>0&&(a=_.setTimeout((function(){S.abort("timeout")}),p.timeout));try{s=!1,i.send(b,y)}catch(e){if(s)throw e;y(-1,e)}}else y(-1,"No Transport");function y(e,t,o,c){var l,u,m,b,A,N=t;s||(s=!0,a&&_.clearTimeout(a),i=void 0,n=c||"",S.readyState=e>0?4:0,l=e>=200&&e<300||304===e,o&&(b=function(e,t,_){for(var i,r,n,o,c=e.contents,a=e.dataTypes;"*"===a[0];)a.shift(),void 0===i&&(i=e.mimeType||t.getResponseHeader("Content-Type"));if(i)for(r in c)if(c[r]&&c[r].test(i)){a.unshift(r);break}if(a[0]in _)n=a[0];else{for(r in _){if(!a[0]||e.converters[r+" "+a[0]]){n=r;break}o||(o=r)}n=n||o}if(n)return n!==a[0]&&a.unshift(n),_[n]}(p,S,o)),b=function(e,t,_,i){var r,n,o,c,a,l={},s=e.dataTypes.slice();if(s[1])for(o in e.converters)l[o.toLowerCase()]=e.converters[o];for(n=s.shift();n;)if(e.responseFields[n]&&(_[e.responseFields[n]]=t),!a&&i&&e.dataFilter&&(t=e.dataFilter(t,e.dataType)),a=n,n=s.shift())if("*"===n)n=a;else if("*"!==a&&a!==n){if(!(o=l[a+" "+n]||l["* "+n]))for(r in l)if((c=r.split(" "))[1]===n&&(o=l[a+" "+c[0]]||l["* "+c[0]])){!0===o?o=l[r]:!0!==l[r]&&(n=c[0],s.unshift(c[1]));break}if(!0!==o)if(o&&e.throws)t=o(t);else try{t=o(t)}catch(e){return{state:"parsererror",error:o?e:"No conversion from "+a+" to "+n}}}return{state:"success",data:t}}(p,b,S,l),l?(p.ifModified&&((A=S.getResponseHeader("Last-Modified"))&&(x.lastModified[r]=A),(A=S.getResponseHeader("etag"))&&(x.etag[r]=A)),204===e||"HEAD"===p.type?N="nocontent":304===e?N="notmodified":(N=b.state,u=b.data,l=!(m=b.error))):(m=N,!e&&N||(N="error",e<0&&(e=0))),S.status=e,S.statusText=(t||N)+"",l?P.resolveWith(f,[u,N,S]):P.rejectWith(f,[S,N,m]),S.statusCode(E),E=void 0,d&&h.trigger(l?"ajaxSuccess":"ajaxError",[S,p,l?u:m]),g.fireWith(f,[S,N]),d&&(h.trigger("ajaxComplete",[S,p]),--x.active||x.event.trigger("ajaxStop")))}return S},getJSON:function(e,t,_){return x.get(e,t,_,"json")},getScript:function(e,t){return x.get(e,void 0,t,"script")}}),x.each(["get","post"],(function(e,t){x[t]=function(e,_,i,r){return g(_)&&(r=r||i,i=_,_=void 0),x.ajax(x.extend({url:e,type:t,dataType:r,data:_,success:i},x.isPlainObject(e)&&e))}})),x._evalUrl=function(e,t){return x.ajax({url:e,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,converters:{"text script":function(){}},dataFilter:function(e){x.globalEval(e,t)}})},x.fn.extend({wrapAll:function(e){var t;return this[0]&&(g(e)&&(e=e.call(this[0])),t=x(e,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&t.insertBefore(this[0]),t.map((function(){for(var e=this;e.firstElementChild;)e=e.firstElementChild;return e})).append(this)),this},wrapInner:function(e){return g(e)?this.each((function(t){x(this).wrapInner(e.call(this,t))})):this.each((function(){var t=x(this),_=t.contents();_.length?_.wrapAll(e):t.append(e)}))},wrap:function(e){var t=g(e);return this.each((function(_){x(this).wrapAll(t?e.call(this,_):e)}))},unwrap:function(e){return this.parent(e).not("body").each((function(){x(this).replaceWith(this.childNodes)})),this}}),x.expr.pseudos.hidden=function(e){return!x.expr.pseudos.visible(e)},x.expr.pseudos.visible=function(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)},x.ajaxSettings.xhr=function(){try{return new _.XMLHttpRequest}catch(e){}};var Kt={0:200,1223:204},Jt=x.ajaxSettings.xhr();P.cors=!!Jt&&"withCredentials"in Jt,P.ajax=Jt=!!Jt,x.ajaxTransport((function(e){var t,i;if(P.cors||Jt&&!e.crossDomain)return{send:function(r,n){var o,c=e.xhr();if(c.open(e.type,e.url,e.async,e.username,e.password),e.xhrFields)for(o in e.xhrFields)c[o]=e.xhrFields[o];for(o in e.mimeType&&c.overrideMimeType&&c.overrideMimeType(e.mimeType),e.crossDomain||r["X-Requested-With"]||(r["X-Requested-With"]="XMLHttpRequest"),r)c.setRequestHeader(o,r[o]);t=function(e){return function(){t&&(t=i=c.onload=c.onerror=c.onabort=c.ontimeout=c.onreadystatechange=null,"abort"===e?c.abort():"error"===e?"number"!=typeof c.status?n(0,"error"):n(c.status,c.statusText):n(Kt[c.status]||c.status,c.statusText,"text"!==(c.responseType||"text")||"string"!=typeof c.responseText?{binary:c.response}:{text:c.responseText},c.getAllResponseHeaders()))}},c.onload=t(),i=c.onerror=c.ontimeout=t("error"),void 0!==c.onabort?c.onabort=i:c.onreadystatechange=function(){4===c.readyState&&_.setTimeout((function(){t&&i()}))},t=t("abort");try{c.send(e.hasContent&&e.data||null)}catch(e){if(t)throw e}},abort:function(){t&&t()}}})),x.ajaxPrefilter((function(e){e.crossDomain&&(e.contents.script=!1)})),x.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(e){return x.globalEval(e),e}}}),x.ajaxPrefilter("script",(function(e){void 0===e.cache&&(e.cache=!1),e.crossDomain&&(e.type="GET")})),x.ajaxTransport("script",(function(e){var t,_;if(e.crossDomain||e.scriptAttrs)return{send:function(i,r){t=x("
LOADING...
\ No newline at end of file +Slickgrid-Universal
LOADING...
\ No newline at end of file diff --git a/package.json b/package.json index 5bf6fc405..024cb9bd9 100644 --- a/package.json +++ b/package.json @@ -9,25 +9,40 @@ "build:demo": "lerna run build:demo --stream", "rebuild": "npm run clean && npm run build", "clean": "rimraf packages/*/dist dist", + "cypress": "cypress open --config-file test/cypress.json", + "cypress:ci": "cypress run --config-file test/cypress.json --reporter mochawesome", "dev:watch": "lerna run dev:watch --parallel", "diff": "lerna diff", "updated": "lerna updated", "clean:tsconfig-build-cache": "rimraf packages/*/dist/tsconfig.tsbuildinfo", "new-version": "lerna version --conventional-commits --yes", + "serve": "http-server ./docs -p 8888 -a localhost", "test": "npx jest --runInBand --coverage --config ./test/jest.config.js", "test:watch": "npx jest --watch --config ./test/jest.config.js" }, - "workspaces": [ - "packages/*" - ], + "workspaces": { + "packages": [ + "packages/*" + ], + "nohoist": [ + "**/mochawesome", + "**/mochawesome/**", + "**/mocha", + "**/mocha/**", + "**/cypress", + "**/cypress/**" + ] + }, "devDependencies": { "@types/jest": "^25.2.3", "@types/node": "^14.0.11", "@typescript-eslint/eslint-plugin": "^3.1.0", "@typescript-eslint/parser": "^3.1.0", + "cypress": "^4.9.0", "eslint": "^7.2.0", "eslint-plugin-import": "^2.20.2", "eslint-plugin-prefer-arrow": "^1.2.1", + "http-server": "^0.12.3", "jest": "^26.0.1", "jest-cli": "^26.0.1", "jest-environment-jsdom": "^26.0.1", @@ -36,6 +51,8 @@ "jsdom": "^16.2.2", "jsdom-global": "^3.0.2", "lerna": "^3.22.0", + "mocha": "^8.0.1", + "mochawesome": "^6.1.1", "ts-jest": "^26.1.0", "typescript": "^3.9.5" }, diff --git a/packages/common/package.json b/packages/common/package.json index f59c34954..b377f6d0e 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -22,15 +22,13 @@ "build:watch": "cross-env tsc --incremental --watch", "dev": "run-s build sass:build sass:copy", "dev:watch": "run-p build:watch sass:watch", - "bundle": "npm-run-all bundle:amd bundle:commonjs bundle:es2015 bundle:es2020 bundle:native-modules bundle:system", + "bundle": "npm-run-all bundle:amd bundle:commonjs bundle:es2015 bundle:es2020", "prebundle": "npm-run-all delete:dist", "postbundle": "npm-run-all sass:build sass:copy", "bundle:amd": "cross-env tsc --project tsconfig.build.json --outDir dist/amd --module amd", "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 es2020 --target es2020", - "bundle:native-modules": "cross-env tsc --project tsconfig.build.json --outDir dist/native-modules --module es2015", - "bundle:system": "cross-env tsc --project tsconfig.build.json --outDir dist/system --module system", "delete:dist": "cross-env rimraf dist", "sass-build-task:scss-compile:bootstrap": "node-sass src/styles/slickgrid-theme-bootstrap.scss -o dist/styles/css --output-style compressed", "postsass-build-task:scss-compile:bootstrap": "postcss --use autoprefixer --output dist/styles/css/slickgrid-theme-bootstrap.css dist/styles/css/slickgrid-theme-bootstrap.css --output-style compressed", diff --git a/packages/common/src/editors/autoCompleteEditor.ts b/packages/common/src/editors/autoCompleteEditor.ts index c6d4b2d1d..53347102b 100644 --- a/packages/common/src/editors/autoCompleteEditor.ts +++ b/packages/common/src/editors/autoCompleteEditor.ts @@ -13,9 +13,6 @@ import { import { findOrDefault, getDescendantProperty, setDeepValue } from '../services/utilities'; import { textValidator } from '../editorValidators/textValidator'; -// using external non-typed js libraries -declare const $: any; - // minimum length of chars to type before starting to start querying const MIN_LENGTH = 3; diff --git a/packages/common/src/editors/dateEditor.ts b/packages/common/src/editors/dateEditor.ts index acdef591c..975442f95 100644 --- a/packages/common/src/editors/dateEditor.ts +++ b/packages/common/src/editors/dateEditor.ts @@ -21,11 +21,6 @@ import { import { mapFlatpickrDateFormatWithFieldType, mapMomentDateFormatWithFieldType, setDeepValue, getDescendantProperty } from './../services/utilities'; import { TranslaterService } from '../services/translater.service'; -// using external non-typed js libraries -declare const $: any; - -declare function require(name: string): any; - /* * An example of a date picker editor using Flatpickr * https://chmln.github.io/flatpickr diff --git a/packages/common/src/editors/longTextEditor.ts b/packages/common/src/editors/longTextEditor.ts index ded31b24c..546c79814 100644 --- a/packages/common/src/editors/longTextEditor.ts +++ b/packages/common/src/editors/longTextEditor.ts @@ -16,9 +16,6 @@ import { getDescendantProperty, getHtmlElementOffset, getTranslationPrefix, setD import { TranslaterService } from '../services/translater.service'; import { textValidator } from '../editorValidators/textValidator'; -// using external non-typed js libraries -declare const $: any; - /* * An example of a 'detached' editor. * The UI is added onto document BODY and .position(), .show() and .hide() are implemented. diff --git a/packages/common/src/editors/selectEditor.ts b/packages/common/src/editors/selectEditor.ts index 13c4529fc..1d137891d 100644 --- a/packages/common/src/editors/selectEditor.ts +++ b/packages/common/src/editors/selectEditor.ts @@ -20,9 +20,6 @@ import { import { CollectionService, findOrDefault, TranslaterService } from '../services/index'; import { charArraysEqual, getDescendantProperty, getTranslationPrefix, htmlEncode, setDeepValue } from '../services/utilities'; -// using external non-typed js libraries -declare const $: any; - /** * Slickgrid editor class for multiple/single select lists */ diff --git a/packages/common/src/editors/sliderEditor.ts b/packages/common/src/editors/sliderEditor.ts index 875a6a39a..8556f5416 100644 --- a/packages/common/src/editors/sliderEditor.ts +++ b/packages/common/src/editors/sliderEditor.ts @@ -1,11 +1,7 @@ -import { Constants } from '../constants'; import { Column, ColumnEditor, Editor, EditorArguments, EditorValidator, EditorValidatorOutput, SlickGrid } from '../interfaces/index'; import { getDescendantProperty, setDeepValue } from '../services/utilities'; import { sliderValidator } from '../editorValidators/sliderValidator'; -// using external non-typed js libraries -declare const $: any; - const DEFAULT_MIN_VALUE = 0; const DEFAULT_MAX_VALUE = 100; const DEFAULT_STEP = 1; 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 b4161a6f3..05a64dd4e 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/extensions/__tests__/cellExternalCopyManagerExtension.spec.ts b/packages/common/src/extensions/__tests__/cellExternalCopyManagerExtension.spec.ts index 5e4f82b6a..3a16110c8 100644 --- a/packages/common/src/extensions/__tests__/cellExternalCopyManagerExtension.spec.ts +++ b/packages/common/src/extensions/__tests__/cellExternalCopyManagerExtension.spec.ts @@ -27,14 +27,13 @@ const mockSelectionModel = jest.fn().mockImplementation(() => ({ init: jest.fn(), destroy: jest.fn() })); - -jest.mock('slickgrid/plugins/slick.cellexternalcopymanager', () => mockAddon); Slick.CellExternalCopyManager = mockAddon; - -jest.mock('slickgrid/plugins/slick.cellselectionmodel', () => mockSelectionModel); Slick.CellSelectionModel = mockSelectionModel; describe('cellExternalCopyManagerExtension', () => { + jest.mock('slickgrid/plugins/slick.cellexternalcopymanager', () => mockAddon); + jest.mock('slickgrid/plugins/slick.cellselectionmodel', () => mockSelectionModel); + let queueCallback: EditCommand; const mockEventCallback = () => { }; const mockSelectRange = [{ fromCell: 1, fromRow: 1, toCell: 1, toRow: 1 }] as CellRange[]; diff --git a/packages/common/src/extensions/__tests__/gridMenuExtension.spec.ts b/packages/common/src/extensions/__tests__/gridMenuExtension.spec.ts index 86b5da031..03da74c4d 100644 --- a/packages/common/src/extensions/__tests__/gridMenuExtension.spec.ts +++ b/packages/common/src/extensions/__tests__/gridMenuExtension.spec.ts @@ -71,7 +71,7 @@ Slick.Controls = { // define a
container to simulate the grid container const template = `
-
+
`; diff --git a/packages/common/src/extensions/extensionUtility.ts b/packages/common/src/extensions/extensionUtility.ts index c913a4fbb..d214b0187 100644 --- a/packages/common/src/extensions/extensionUtility.ts +++ b/packages/common/src/extensions/extensionUtility.ts @@ -5,8 +5,6 @@ import { SharedService } from '../services/shared.service'; import { TranslaterService } from '../services'; import { getTranslationPrefix } from '../services/utilities'; -declare function require(name: string): any; - export class ExtensionUtility { constructor(private sharedService: SharedService, private translaterService: TranslaterService) { } diff --git a/packages/common/src/extensions/gridMenuExtension.ts b/packages/common/src/extensions/gridMenuExtension.ts index ed0b95b0b..47771e8bd 100644 --- a/packages/common/src/extensions/gridMenuExtension.ts +++ b/packages/common/src/extensions/gridMenuExtension.ts @@ -26,9 +26,8 @@ import { TranslaterService } from '../services/translater.service'; import { refreshBackendDataset } from '../services/backend-utilities'; import { getTranslationPrefix } from '../services/utilities'; -// using external non-typed js libraries +// using external js libraries declare const Slick: SlickNamespace; -declare const $: any; export class GridMenuExtension implements Extension { private _addon: SlickGridMenu | null; diff --git a/packages/common/src/filters/autoCompleteFilter.ts b/packages/common/src/filters/autoCompleteFilter.ts index d8a33b55e..f7bd61d4e 100644 --- a/packages/common/src/filters/autoCompleteFilter.ts +++ b/packages/common/src/filters/autoCompleteFilter.ts @@ -18,9 +18,6 @@ import { import { CollectionService } from '../services/collection.service'; import { getDescendantProperty } from '../services/utilities'; -// using external non-typed js libraries -declare const $: any; - export class AutoCompleteFilter implements Filter { private _autoCompleteOptions: AutocompleteOption; private _clearFilterTriggered = false; diff --git a/packages/common/src/filters/compoundDateFilter.ts b/packages/common/src/filters/compoundDateFilter.ts index 00ed352a1..0e03d2048 100644 --- a/packages/common/src/filters/compoundDateFilter.ts +++ b/packages/common/src/filters/compoundDateFilter.ts @@ -22,12 +22,6 @@ import { import { mapFlatpickrDateFormatWithFieldType, mapOperatorToShorthandDesignation } from '../services/utilities'; import { TranslaterService } from '../services/translater.service'; -// use Flatpickr from import or 'require', whichever works first -declare function require(name: string): any; - -// using external non-typed js libraries -declare const $: any; - export class CompoundDateFilter implements Filter { private _clearFilterTriggered = false; private _currentDate: Date | undefined; @@ -280,7 +274,8 @@ export class CompoundDateFilter implements Filter { this.$filterInputElm.data('columnId', columnId); if (this.operator) { - this.$selectOperatorElm.val(this.operator); + const operatorShorthand = mapOperatorToShorthandDesignation(this.operator); + this.$selectOperatorElm.val(operatorShorthand); } // if there's a search term, we will add the "filled" class for styling purposes diff --git a/packages/common/src/filters/compoundInputFilter.ts b/packages/common/src/filters/compoundInputFilter.ts index d1f66be09..f06b6f697 100644 --- a/packages/common/src/filters/compoundInputFilter.ts +++ b/packages/common/src/filters/compoundInputFilter.ts @@ -13,9 +13,6 @@ import { import { getTranslationPrefix, mapOperatorToShorthandDesignation } from '../services/utilities'; import { TranslaterService } from '../services/translater.service'; -// using external non-typed js libraries -declare const $: any; - export class CompoundInputFilter implements Filter { private _clearFilterTriggered = false; private _shouldTriggerQuery = true; @@ -227,7 +224,8 @@ export class CompoundInputFilter implements Filter { this.$filterInputElm.data('columnId', columnId); if (this.operator) { - this.$selectOperatorElm.val(this.operator); + const operatorShorthand = mapOperatorToShorthandDesignation(this.operator); + this.$selectOperatorElm.val(operatorShorthand); } // if there's a search term, we will add the "filled" class for styling purposes diff --git a/packages/common/src/filters/compoundSliderFilter.ts b/packages/common/src/filters/compoundSliderFilter.ts index 327a0b44e..078622d28 100644 --- a/packages/common/src/filters/compoundSliderFilter.ts +++ b/packages/common/src/filters/compoundSliderFilter.ts @@ -9,9 +9,6 @@ import { } from '../interfaces/index'; import { mapOperatorToShorthandDesignation } from '../services/utilities'; -// using external non-typed js libraries -declare const $: any; - const DEFAULT_MIN_VALUE = 0; const DEFAULT_MAX_VALUE = 100; const DEFAULT_STEP = 1; @@ -254,7 +251,8 @@ export class CompoundSliderFilter implements Filter { this.$filterInputElm.data('columnId', columnId); if (this.operator) { - this.$selectOperatorElm.val(this.operator); + const operatorShorthand = mapOperatorToShorthandDesignation(this.operator); + this.$selectOperatorElm.val(operatorShorthand); } // if there's a search term, we will add the "filled" class for styling purposes diff --git a/packages/common/src/filters/dateRangeFilter.ts b/packages/common/src/filters/dateRangeFilter.ts index 7b93d49ad..6ad3255bf 100644 --- a/packages/common/src/filters/dateRangeFilter.ts +++ b/packages/common/src/filters/dateRangeFilter.ts @@ -21,12 +21,6 @@ import { import { mapFlatpickrDateFormatWithFieldType, mapMomentDateFormatWithFieldType } from '../services/utilities'; import { TranslaterService } from '../services/translater.service'; -// using external non-typed js libraries -declare const $: any; - -declare function require(name: string): any; -declare function require(name: string[], loadedFile: any): any; - export class DateRangeFilter implements Filter { private _clearFilterTriggered = false; private _currentValue: string; diff --git a/packages/common/src/global-grid-options.ts b/packages/common/src/global-grid-options.ts index 9377c12d8..2e5f93245 100644 --- a/packages/common/src/global-grid-options.ts +++ b/packages/common/src/global-grid-options.ts @@ -170,11 +170,11 @@ export const GlobalGridOptions: GridOption = { tristateMultiColumnSort: false, sortColNumberInSeparateSpan: true, suppressActiveCellChangeOnEdit: true, - // pagination: { - // pageSizes: [10, 15, 20, 25, 30, 40, 50, 75, 100], - // pageSize: 25, - // totalItems: 0 - // }, + pagination: { + pageSizes: [10, 15, 20, 25, 30, 40, 50, 75, 100], + pageSize: 25, + totalItems: 0 + }, // // @ts-ignore // // technically speaking the Row Detail requires the process & viewComponent but we'll ignore it just to set certain options // rowDetailView: { 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 57ead618e..b9fb19f7e 100644 --- a/packages/common/src/interfaces/index.ts +++ b/packages/common/src/interfaces/index.ts @@ -59,7 +59,6 @@ export * from './flatpickrOption.interface'; export * from './formatter.interface'; export * from './formatterOption.interface'; export * from './formatterResultObject.interface'; -export * from './gridSize.interface'; export * from './gridMenu.interface'; export * from './gridMenuItem.interface'; export * from './gridMenuOption.interface'; @@ -67,6 +66,7 @@ export * from './gridOption.interface'; export * from './gridServiceDeleteOption.interface'; export * from './gridServiceInsertOption.interface'; export * from './gridServiceUpdateOption.interface'; +export * from './gridSize.interface'; export * from './gridState.interface'; export * from './gridStateChange.interface'; export * from './grouping.interface'; @@ -113,22 +113,22 @@ export * from './slickCellMenu.interface'; export * from './slickCellRangeDecorator.interface'; export * from './slickCellRangeSelector.interface'; export * from './slickCellSelectionModel.interface'; -export * from './slickCheckboxSelectColumn.interface'; export * from './slickColumnPicker.interface'; +export * from './slickCheckboxSelectColumn.interface'; export * from './slickContextMenu.interface'; export * from './slickDataView.interface'; export * from './slickDraggableGrouping.interface'; export * from './slickEditorLock.interface'; export * from './slickEvent.interface'; export * from './slickEventData.interface'; -export * from './slickEventHandler.interface'; export * from './slickGrid.interface'; +export * from './slickEventHandler.interface'; export * from './slickGridMenu.interface'; export * from './slickGroupItemMetadataProvider.interface'; export * from './slickHeaderButtons.interface'; -export * from './slickHeaderMenu.interface'; export * from './slickNamespace.interface'; export * from './slickRange.interface'; +export * from './slickHeaderMenu.interface'; export * from './slickResizer.interface'; export * from './slickRowDetailView.interface'; export * from './slickRowMoveManager.interface'; diff --git a/packages/common/src/interfaces/slickGrid.interface.ts b/packages/common/src/interfaces/slickGrid.interface.ts index 6b7168ec4..7f1ea2802 100644 --- a/packages/common/src/interfaces/slickGrid.interface.ts +++ b/packages/common/src/interfaces/slickGrid.interface.ts @@ -390,9 +390,10 @@ export interface SlickGrid { /** * Extends grid options with a given hash. If an there is an active edit, the grid will attempt to commit the changes and only continue if the attempt succeeds. - * @options An object with configuration options. + * @params options An object with configuration options. + * @params do we want to supress the grid re-rendering? (defaults to false) */ - setOptions(options: GridOption): void; + setOptions(options: GridOption, suppressRender?: boolean): void; /** Set the Pre-Header Visibility and optionally enable/disable animation (enabled by default) */ setPreHeaderPanelVisibility(visible: boolean, animate?: boolean): void; diff --git a/packages/common/src/interfaces/slickNamespace.interface.ts b/packages/common/src/interfaces/slickNamespace.interface.ts index 3f38eba3e..72b93abb7 100644 --- a/packages/common/src/interfaces/slickNamespace.interface.ts +++ b/packages/common/src/interfaces/slickNamespace.interface.ts @@ -68,7 +68,7 @@ export interface SlickNamespace { }; /** Slick Grid is a data grid library and this class is the core of the library */ - Grid: new (gridContainer: Element, data: SlickDataView | Array, columnDefinitions: Column[], gridOptions: GridOption) => SlickGrid; + Grid: new (gridContainer: HTMLElement, data: SlickDataView | Array, columnDefinitions: Column[], gridOptions: GridOption) => SlickGrid; // -- diff --git a/packages/common/src/services/__tests__/groupingAndColspan.service.spec.ts b/packages/common/src/services/__tests__/groupingAndColspan.service.spec.ts index c640c92b9..52533b6f5 100644 --- a/packages/common/src/services/__tests__/groupingAndColspan.service.spec.ts +++ b/packages/common/src/services/__tests__/groupingAndColspan.service.spec.ts @@ -65,7 +65,7 @@ jest.useFakeTimers(); // define a
container to simulate the grid container const template = `
-
+
diff --git a/packages/common/src/services/__tests__/pagination.service.spec.ts b/packages/common/src/services/__tests__/pagination.service.spec.ts index 52b536a45..79f9e858e 100644 --- a/packages/common/src/services/__tests__/pagination.service.spec.ts +++ b/packages/common/src/services/__tests__/pagination.service.spec.ts @@ -113,7 +113,6 @@ describe('PaginationService', () => { }); it('should initialize the service and be able to change the grid options by the SETTER and expect the GETTER to have updated options', () => { - const mockGridOptionCopy = { ...mockGridOption, options: null }; service.init(gridStub, mockGridOption.pagination, mockGridOption.backendServiceApi); service.paginationOptions = mockGridOption.pagination; @@ -128,7 +127,7 @@ describe('PaginationService', () => { expect(service.totalItems).toEqual(125); expect(service.getCurrentPageNumber()).toBe(2); - expect(spy).toHaveBeenCalledWith(false, false); + expect(spy).toHaveBeenCalledWith(false, false, true); }); it('should be able to change the totalItems by the SETTER after the initialization and expect the "refreshPagination" method to be called', () => { @@ -731,7 +730,7 @@ describe('PaginationService', () => { it('should trigger "onShowPaginationChanged" without calling the DataView when using Backend Services', () => { const pubSubSpy = jest.spyOn(mockPubSub, 'publish'); const setPagingSpy = jest.spyOn(dataviewStub, 'setPagingOptions'); - + const expectedPagination = { dataFrom: 26, dataTo: 50, pageCount: 4, pageNumber: 2, pageSize: 25, pageSizes: [5, 10, 15, 20,], totalItems: 85, }; mockGridOption.backendServiceApi = { service: mockBackendService, process: jest.fn(), @@ -741,7 +740,9 @@ describe('PaginationService', () => { service.togglePaginationVisibility(false); expect(sharedService.gridOptions.enablePagination).toBeFalse(); - expect(pubSubSpy).toHaveBeenNthCalledWith(1, `onPaginationVisibilityChanged`, { visible: false }); + expect(pubSubSpy).toHaveBeenNthCalledWith(1, `onPaginationRefreshed`, expectedPagination); + expect(pubSubSpy).toHaveBeenNthCalledWith(2, `onPaginationPresetsInitialized`, expectedPagination); + expect(pubSubSpy).toHaveBeenNthCalledWith(3, `onPaginationVisibilityChanged`, { visible: false }); expect(setPagingSpy).not.toHaveBeenCalled(); }); @@ -754,7 +755,7 @@ describe('PaginationService', () => { service.togglePaginationVisibility(false); expect(sharedService.gridOptions.enablePagination).toBeFalse(); - expect(pubSubSpy).toHaveBeenNthCalledWith(1, `onPaginationVisibilityChanged`, { visible: false }); + expect(pubSubSpy).toHaveBeenNthCalledWith(3, `onPaginationVisibilityChanged`, { visible: false }); expect(setPagingSpy).toHaveBeenCalledWith({ pageSize: 0, pageNum: 0 }); }); @@ -769,7 +770,7 @@ describe('PaginationService', () => { expect(sharedService.gridOptions.enablePagination).toBeTrue(); expect(gotoSpy).toHaveBeenCalled(); - expect(pubSubSpy).toHaveBeenNthCalledWith(1, `onPaginationVisibilityChanged`, { visible: true }); + expect(pubSubSpy).toHaveBeenNthCalledWith(3, `onPaginationVisibilityChanged`, { visible: true }); expect(setPagingSpy).toHaveBeenCalledWith({ pageSize: mockGridOption.pagination.pageSize, pageNum: 0 }); }); }); diff --git a/packages/common/src/services/groupingAndColspan.service.ts b/packages/common/src/services/groupingAndColspan.service.ts index 7741abb85..d84c48aec 100644 --- a/packages/common/src/services/groupingAndColspan.service.ts +++ b/packages/common/src/services/groupingAndColspan.service.ts @@ -8,14 +8,12 @@ import { SlickNamespace, SlickResizer, SlickColumnPicker, - SlickGridMenu, } from './../interfaces/index'; import { ExtensionName } from '../enums/index'; import { ExtensionUtility } from '../extensions/extensionUtility'; import { ExtensionService } from '../services/extension.service'; // using external non-typed js libraries -declare let $: any; declare const Slick: SlickNamespace; export class GroupingAndColspanService { 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/common/src/services/pagination.service.ts b/packages/common/src/services/pagination.service.ts index f9e341558..ee0623ef3 100644 --- a/packages/common/src/services/pagination.service.ts +++ b/packages/common/src/services/pagination.service.ts @@ -1,7 +1,7 @@ import * as isequal_ from 'lodash.isequal'; const isequal = isequal_; // patch to fix rollup to work -import { BackendServiceApi, CurrentPagination, SlickDataView, Pagination, ServicePagination, SlickGrid, Subscription, SlickEventData, SlickNamespace } from '../interfaces/index'; +import { BackendServiceApi, CurrentPagination, Pagination, ServicePagination, SlickDataView, SlickEventData, SlickGrid, Subscription } from '../interfaces/index'; import { executeBackendProcessesCallback, onBackendError } from './backend-utilities'; import { SharedService } from './shared.service'; import { PubSubService } from './pubSub.service'; @@ -27,7 +27,7 @@ export class PaginationService { constructor(private pubSubService: PubSubService, private sharedService: SharedService) { } /** Getter of SlickGrid DataView object */ - get dataView(): SlickDataView { + get dataView(): SlickDataView | undefined { return (this.grid?.getData && this.grid.getData()) as SlickDataView; } @@ -68,7 +68,6 @@ export class PaginationService { set totalItems(totalItems: number) { this._totalItems = totalItems; - if (this._initialized) { this.refreshPagination(); } @@ -89,14 +88,14 @@ export class PaginationService { if (this._isLocalGrid && this.dataView) { this.dataView.onPagingInfoChanged.subscribe((_e: SlickEventData, pagingInfo: { totalRows: number; pageNum: number; }) => { if (this._totalItems !== pagingInfo.totalRows) { - this._totalItems = pagingInfo.totalRows; - this._paginationOptions.totalItems = this._totalItems; - this.refreshPagination(false, false); + this.updateTotalItems(pagingInfo.totalRows); } }); setTimeout(() => { - this.dataView.setRefreshHints({ isFilterUnchanged: true }); - this.dataView.setPagingOptions({ pageSize: this.paginationOptions.pageSize, pageNum: (this._pageNumber - 1) }); // dataView page starts at 0 instead of 1 + if (this.dataView) { + this.dataView.setRefreshHints({ isFilterUnchanged: true }); + this.dataView.setPagingOptions({ pageSize: this.paginationOptions.pageSize, pageNum: (this._pageNumber - 1) }); // dataView page starts at 0 instead of 1 + } }); } @@ -113,7 +112,7 @@ export class PaginationService { this._subscriptions.push(this.pubSubService.subscribe(`onItemDeleted`, (items: any | any[]) => this.processOnItemAddedOrRemoved(items, false))); } - this.refreshPagination(false, false); + this.refreshPagination(false, false, true); this._initialized = true; } @@ -152,33 +151,32 @@ export class PaginationService { return this._itemsPerPage; } - changeItemPerPage(itemsPerPage: number, event?: any): Promise { + changeItemPerPage(itemsPerPage: number, event?: any): Promise { this._pageNumber = 1; this._pageCount = Math.ceil(this._totalItems / itemsPerPage); this._itemsPerPage = itemsPerPage; return this.processOnPageChanged(this._pageNumber, event); } - goToFirstPage(event?: any): Promise { + goToFirstPage(event?: any): Promise { this._pageNumber = 1; return this.processOnPageChanged(this._pageNumber, event); } - goToLastPage(event?: any): Promise { + goToLastPage(event?: any): Promise { this._pageNumber = this._pageCount || 1; return this.processOnPageChanged(this._pageNumber || 1, event); } - goToNextPage(event?: any): Promise { + goToNextPage(event?: any): Promise { if (this._pageNumber < this._pageCount) { this._pageNumber++; return this.processOnPageChanged(this._pageNumber, event); - } else { - return new Promise(resolve => resolve(false)); } + return new Promise(resolve => resolve(false)); } - goToPageNumber(pageNumber: number, event?: any): Promise { + goToPageNumber(pageNumber: number, event?: any): Promise { const previousPageNumber = this._pageNumber; if (pageNumber < 1) { @@ -191,22 +189,20 @@ export class PaginationService { if (this._pageNumber !== previousPageNumber) { return this.processOnPageChanged(this._pageNumber, event); - } else { - return new Promise(resolve => resolve(false)); } + return new Promise(resolve => resolve(false)); } - goToPreviousPage(event?: any): Promise { + goToPreviousPage(event?: any): Promise { if (this._pageNumber > 1) { this._pageNumber--; return this.processOnPageChanged(this._pageNumber, event); - } else { - return new Promise(resolve => resolve(false)); } + return new Promise(resolve => resolve(false)); } - refreshPagination(isPageNumberReset = false, triggerChangedEvent = true) { - const previousPagination = { ...this.getCurrentPagination() }; + refreshPagination(isPageNumberReset = false, triggerChangedEvent = true, triggerInitializedEvent = false) { + const previousPagination = { ...this.getFullPagination() }; if (this._paginationOptions) { const pagination = this._paginationOptions; @@ -216,7 +212,7 @@ export class PaginationService { if (this._isLocalGrid) { this._itemsPerPage = pagination.pageSize; } else { - this._itemsPerPage = +((this._backendServiceApi && this._backendServiceApi.options && this._backendServiceApi.options.paginationOptions && this._backendServiceApi.options.paginationOptions.first) ? this._backendServiceApi.options.paginationOptions.first : pagination.pageSize); + this._itemsPerPage = +((this._backendServiceApi?.options?.paginationOptions?.first) ? this._backendServiceApi.options.paginationOptions.first : pagination.pageSize); } } @@ -243,18 +239,26 @@ export class PaginationService { this.recalculateFromToIndexes(); } this._pageCount = Math.ceil(this._totalItems / this._itemsPerPage); - const currentPagination = this.getCurrentPagination(); - this.sharedService.currentPagination = currentPagination; + this.sharedService.currentPagination = this.getCurrentPagination(); + + // publish the refresh event on anytime the pagination is refreshed or re-rendered (run every time) + // useful when binding a slick-pagination View + this.pubSubService.publish(`onPaginationRefreshed`, this.getFullPagination()); - if (triggerChangedEvent && !isequal(previousPagination, currentPagination)) { + // publish a pagination change only when flag requires it (triggered by page or pageSize change, dataset length change by a filter or others) + if (triggerChangedEvent && !isequal(previousPagination, this.getFullPagination())) { this.pubSubService.publish(`onPaginationChanged`, this.getFullPagination()); } - this.sharedService.currentPagination = this.getCurrentPagination(); + + // publish on the first pagination initialization (called by the "init()" method on first load) + if (triggerInitializedEvent && !isequal(previousPagination, this.getFullPagination())) { + this.pubSubService.publish(`onPaginationPresetsInitialized`, this.getFullPagination()); + } } /** Reset the Pagination to first page and recalculate necessary numbers */ resetPagination(triggerChangedEvent = true) { - if (this._isLocalGrid) { + if (this._isLocalGrid && this.dataView) { // on a local grid we also need to reset the DataView paging to 1st page this.dataView.setPagingOptions({ pageSize: this._itemsPerPage, pageNum: 0 }); } @@ -269,7 +273,7 @@ export class PaginationService { * Basically this method WILL NOT WORK to show the Pagination if it was not there from the start. */ togglePaginationVisibility(visible?: boolean) { - if (this.grid && this.sharedService && this.sharedService.gridOptions) { + if (this.grid && this.sharedService?.gridOptions) { const isVisible = visible !== undefined ? visible : !this.sharedService.gridOptions.enablePagination; this.sharedService.gridOptions.enablePagination = isVisible; this.pubSubService.publish(`onPaginationVisibilityChanged`, { visible: isVisible }); @@ -281,20 +285,22 @@ export class PaginationService { // when using a local grid, we can reset the DataView pagination by changing its page size // page size of 0 would show all, hence cancel the pagination - if (this._isLocalGrid) { + if (this._isLocalGrid && this.dataView) { const pageSize = visible ? this._itemsPerPage : 0; this.dataView.setPagingOptions({ pageSize, pageNum: 0 }); } } } - processOnPageChanged(pageNumber: number, event?: Event | undefined): Promise { + processOnPageChanged(pageNumber: number, event?: Event | undefined): Promise { return new Promise((resolve, reject) => { this.recalculateFromToIndexes(); - if (this._isLocalGrid) { + if (this._isLocalGrid && this.dataView) { this.dataView.setPagingOptions({ pageSize: this._itemsPerPage, pageNum: (pageNumber - 1) }); // dataView page starts at 0 instead of 1 this.pubSubService.publish(`onPaginationChanged`, this.getFullPagination()); + this.pubSubService.publish(`onPaginationRefreshed`, this.getFullPagination()); + resolve(this.getFullPagination()); } else { const itemsPerPage = +this._itemsPerPage; @@ -322,8 +328,9 @@ export class PaginationService { reject(process); }); } + this.pubSubService.publish(`onPaginationRefreshed`, this.getFullPagination()); + this.pubSubService.publish(`onPaginationChanged`, this.getFullPagination()); } - this.pubSubService.publish(`onPaginationChanged`, this.getFullPagination()); } }); } @@ -352,6 +359,14 @@ export class PaginationService { } } + updateTotalItems(totalItems: number, triggerChangedEvent = false) { + this._totalItems = totalItems; + if (this._paginationOptions) { + this._paginationOptions.totalItems = totalItems; + this.refreshPagination(false, triggerChangedEvent); + } + } + // -- // private functions // -------------------- @@ -367,7 +382,7 @@ export class PaginationService { if (items !== null) { const previousDataTo = this._dataTo; const itemCount = Array.isArray(items) ? items.length : 1; - const itemCountWithDirection = isItemAdded ? +itemCount : -itemCount; + const itemCountWithDirection = isItemAdded ? +(itemCount) : -(itemCount); // refresh the total count in the pagination and in the UI this._totalItems += itemCountWithDirection; diff --git a/packages/common/src/services/translater.service.ts b/packages/common/src/services/translater.service.ts index 008bb2d84..5087d86a9 100644 --- a/packages/common/src/services/translater.service.ts +++ b/packages/common/src/services/translater.service.ts @@ -11,7 +11,7 @@ export abstract class TranslaterService { * Method to set the locale to use in the App * @param locale */ - setLocale(locale: string): Promise { + setLocale(locale: string): Promise | any { throw new Error('TranslaterService "setLocale" method must be implemented'); } diff --git a/packages/common/src/styles/_variables.scss b/packages/common/src/styles/_variables.scss index f3348f7eb..e6ab6c150 100644 --- a/packages/common/src/styles/_variables.scss +++ b/packages/common/src/styles/_variables.scss @@ -572,6 +572,8 @@ $multiselect-ok-button-text-align: center !default; /* pagination variables */ $pagination-button-hover-color: #E6E6E6 !default; +$pagination-button-border-radius: 4px !default; +$pagination-button-padding: 6px 12px !default; $pagination-border-color: #ddd !default; $pagination-count-margin-left: 2px !default; $pagination-icon-color: $primary-color !default; @@ -589,6 +591,7 @@ $pagination-border-top: 0 none !default; $pagination-border-right: 0 none !default; $pagination-border-bottom: 0 none !default; $pagination-border-left: 0 none !default; +$pagination-page-input-border-radius: 4px !default; $pagination-page-input-bgcolor: #fafbed !default; $pagination-page-input-height: 26px !default; $pagination-page-input-width: 50px !default; diff --git a/packages/common/src/styles/slick-pagination.scss b/packages/common/src/styles/slick-pagination.scss index 432a76046..2e810d580 100644 --- a/packages/common/src/styles/slick-pagination.scss +++ b/packages/common/src/styles/slick-pagination.scss @@ -46,6 +46,7 @@ height: $pagination-page-input-height; width: $pagination-page-input-width; padding: $pagination-page-input-padding; + border-radius: $pagination-page-input-border-radius; display: inline-block; } } @@ -68,11 +69,25 @@ text-decoration: none; font-family: $icon-font-family; -webkit-text-stroke: $pagination-icon-seek-text-stroke; + padding: $pagination-button-padding; } a[class*="icon-seek-"]:hover { background-color: $pagination-button-hover-color; } + &:first-child { + a, span { + border-top-left-radius: $pagination-button-border-radius; + border-bottom-left-radius: $pagination-button-border-radius; + } + } + &:last-child { + a, span { + border-top-right-radius: $pagination-button-border-radius; + border-bottom-right-radius: $pagination-button-border-radius; + } + } + .icon-seek-first { &:before { content: $pagination-icon-seek-first; diff --git a/packages/common/src/styles/slick-without-bootstrap-min-styling.scss b/packages/common/src/styles/slick-without-bootstrap-min-styling.scss index 2dc164f4f..786111456 100644 --- a/packages/common/src/styles/slick-without-bootstrap-min-styling.scss +++ b/packages/common/src/styles/slick-without-bootstrap-min-styling.scss @@ -33,7 +33,7 @@ border-radius: 3px; } -.slick-pane { +.gridPane, .grid-pane { font-family: $font-family; .form-control { @@ -126,4 +126,24 @@ border-color: lighten($primary-color, 10%); box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 6px rgba(lighten($primary-color, 3%), .3); } + + .slick-pagination { + .slick-pagination-nav { + .pagination > li { + a, span { + position: relative; + float: left; + margin-left: -1px; + } + } + .pagination > .disabled > a, + .pagination > .disabled > a:focus, + .pagination > .disabled > a:hover, + .pagination > .disabled > span, + .pagination > .disabled > span:focus, + .pagination > .disabled > span:hover { + cursor: not-allowed; + } + } + } } diff --git a/packages/common/src/styles/slickgrid-examples.scss b/packages/common/src/styles/slickgrid-examples.scss index 239e9a031..f2497a5e7 100644 --- a/packages/common/src/styles/slickgrid-examples.scss +++ b/packages/common/src/styles/slickgrid-examples.scss @@ -3,7 +3,7 @@ .full-height { height: 100%; } -.gridPane { +.grid-pane { width: 100%; } .grid-header { diff --git a/packages/common/src/styles/slickgrid-theme-material.scss b/packages/common/src/styles/slickgrid-theme-material.scss index 929d08988..6f067e3d9 100644 --- a/packages/common/src/styles/slickgrid-theme-material.scss +++ b/packages/common/src/styles/slickgrid-theme-material.scss @@ -118,6 +118,9 @@ $pagination-icon-seek-first-width: $icon-width !default; $pagination-icon-seek-prev-width: $icon-width !default; $pagination-icon-seek-next-width: $icon-width !default; $pagination-icon-seek-end-width: $icon-width !default; +$pagination-button-padding: 6px 9px !default; +$pagination-button-border-radius: 2px !default; +$pagination-page-input-border-radius: 3px !default; @import './roboto-font'; @import './slick-default-theme'; diff --git a/packages/common/src/styles/slickgrid-theme-salesforce.scss b/packages/common/src/styles/slickgrid-theme-salesforce.scss index a0ac3014d..75252e4be 100644 --- a/packages/common/src/styles/slickgrid-theme-salesforce.scss +++ b/packages/common/src/styles/slickgrid-theme-salesforce.scss @@ -145,6 +145,9 @@ $pagination-icon-seek-first-width: $icon-width !default; $pagination-icon-seek-prev-width: $icon-width !default; $pagination-icon-seek-next-width: $icon-width !default; $pagination-icon-seek-end-width: $icon-width !default; +$pagination-button-padding: 6px 9px !default; +$pagination-button-border-radius: 2px !default; +$pagination-page-input-border-radius: 3px !default; @import './slick-without-bootstrap-min-styling'; @import './slick-default-theme'; diff --git a/packages/excel-export/package.json b/packages/excel-export/package.json index 59acc4965..bf9cbef62 100644 --- a/packages/excel-export/package.json +++ b/packages/excel-export/package.json @@ -6,6 +6,9 @@ "main": "dist/commonjs/index.js", "module": "dist/es2015/index.js", "typings": "dist/commonjs/index.d.ts", + "publishConfig": { + "access": "public" + }, "files": [ "src", "dist" diff --git a/packages/excel-export/tsconfig.json b/packages/excel-export/tsconfig.json index c47e43b8e..f4e0c7a65 100644 --- a/packages/excel-export/tsconfig.json +++ b/packages/excel-export/tsconfig.json @@ -7,7 +7,7 @@ "declarationDir": "dist/commonjs", "outDir": "dist/commonjs", "target": "es2015", - "module": "esnext", + "module": "commonjs", "sourceMap": true, "allowSyntheticDefaultImports": true, "noImplicitReturns": true, diff --git a/packages/file-export/package.json b/packages/file-export/package.json index 19a143ba3..1f4931310 100644 --- a/packages/file-export/package.json +++ b/packages/file-export/package.json @@ -6,6 +6,9 @@ "main": "dist/commonjs/index.js", "module": "dist/es2015/index.js", "typings": "dist/commonjs/index.d.ts", + "publishConfig": { + "access": "public" + }, "files": [ "src", "dist" @@ -15,10 +18,10 @@ "build:watch": "cross-env tsc --incremental --watch", "dev": "run-s build sass:build sass:copy", "dev:watch": "run-p build:watch", + "bundle": "npm-run-all bundle:commonjs bundle:es2015 bundle:es2020", "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" }, diff --git a/packages/file-export/src/fileExport.service.spec.ts b/packages/file-export/src/fileExport.service.spec.ts index 5394a001c..9804809ca 100644 --- a/packages/file-export/src/fileExport.service.spec.ts +++ b/packages/file-export/src/fileExport.service.spec.ts @@ -2,6 +2,7 @@ import { FileExportService } from './fileExport.service'; import { Column, DelimiterType, + ExportOption, FieldType, FileType, Formatter, @@ -69,7 +70,7 @@ describe('ExportService', () => { let sharedService: SharedService; let translateService: TranslateServiceStub; let mockColumns: Column[]; - let mockExportCsvOptions; + let mockExportCsvOptions: ExportOption; let mockExportTxtOptions; let mockCsvBlob: Blob; let mockTxtBlob: Blob; diff --git a/packages/file-export/tsconfig.build.json b/packages/file-export/tsconfig.build.json index 407988bc5..7f944d5ba 100644 --- a/packages/file-export/tsconfig.build.json +++ b/packages/file-export/tsconfig.build.json @@ -7,6 +7,13 @@ "es2020", "dom" ], + "types": [ + "moment", + "jest", + "jest-extended", + "jquery", + "node" + ], "typeRoots": [ "../typings", "../../node_modules/@types" diff --git a/packages/file-export/tsconfig.json b/packages/file-export/tsconfig.json index 41e6266de..b8f33d5a6 100644 --- a/packages/file-export/tsconfig.json +++ b/packages/file-export/tsconfig.json @@ -6,16 +6,17 @@ "declarationDir": "dist/commonjs", "outDir": "dist/commonjs", "target": "es2015", - "module": "esnext", + "module": "commonjs", "sourceMap": true, - "allowSyntheticDefaultImports": true, "noImplicitReturns": true, "lib": [ + "es2015", "es2020", "dom" ], "types": [ "moment", + "jquery", "node" ], "typeRoots": [ 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..08517f31a --- /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": "npm-run-all bundle:commonjs bundle:es2015 bundle:es2020", + "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", + "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..54bf559c8 --- /dev/null +++ b/packages/graphql/src/index.spec.ts @@ -0,0 +1,17 @@ +import * as entry from './index'; +import * as interfaces from './interfaces/index'; +import { GraphqlService, GraphqlQueryBuilder } from './services/index'; + +describe('Testing GraphQL Package entry point', () => { + it('should have multiple index entries defined', () => { + expect(entry).toBeTruthy(); + expect(interfaces).toBeTruthy(); + expect(GraphqlService).toBeTruthy(); + expect(GraphqlQueryBuilder).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..0e31cc612 --- /dev/null +++ b/packages/graphql/src/index.ts @@ -0,0 +1,3 @@ +export { GraphqlService } from './services/graphql.service'; +export { default as GraphqlQueryBuilder } from './services/graphqlQueryBuilder'; +export * from './interfaces/index'; 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..6ec596577 --- /dev/null +++ b/packages/graphql/src/services/__tests__/graphql.service.spec.ts @@ -0,0 +1,1291 @@ +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 EndsWith when the Column Filter was provided as EndsWith', () => { + 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: 'EndsWith' } } 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 the operator StartsWith when the operator was provided as StartsWith', () => { + 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: 'StartsWith' }, + } 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..d1d15baa0 --- /dev/null +++ b/packages/graphql/src/services/graphql.service.ts @@ -0,0 +1,616 @@ +import { + // utilities + mapOperatorType, + mapOperatorByFieldType, + + // enums/interfaces + BackendService, + Column, + ColumnFilter, + ColumnFilters, + ColumnSort, + CurrentFilter, + CurrentPagination, + CurrentSorter, + FieldType, + FilterChangedArgs, + GridOption, + MultiColumnSort, + OperatorString, + OperatorType, + Pagination, + PaginationChangedArgs, + SingleColumnSort, + SlickGrid, + 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: SlickGrid | undefined; + private _datasetIdPropName = 'id'; + options: GraphqlServiceOption | undefined; + 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?.getOptions) ? this._grid.getOptions() : {}; + } + + /** Initialization of the service, which acts as a constructor */ + init(serviceOptions: GraphqlServiceOption, pagination?: Pagination, grid?: SlickGrid): 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 } as GraphqlServiceOption; + } + + /* + * 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: SingleColumnSort | MultiColumnSort): string { + const sortColumns = (args.multiColumnSort) ? (args as MultiColumnSort).sortCols : new Array({ columnId: (args as ColumnSort).sortCol.id, 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; + }) as { columnId: string | number; sortAsc: boolean; }[] | 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) { + 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..14cd76e9c --- /dev/null +++ b/packages/graphql/tsconfig.build.json @@ -0,0 +1,47 @@ +{ + "compilerOptions": { + "module": "esnext", + "moduleResolution": "node", + "target": "es2015", + "lib": [ + "es2020", + "dom" + ], + "types": [ + "moment", + "jest", + "jest-extended", + "jquery", + "node" + ], + "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..b5fab81ea --- /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": "commonjs", + "sourceMap": true, + "noImplicitReturns": true, + "lib": [ + "es2020", + "dom" + ], + "types": [ + "moment", + "jquery", + "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..96ed61f01 --- /dev/null +++ b/packages/odata/package.json @@ -0,0 +1,43 @@ +{ + "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", + "publishConfig": { + "access": "public" + }, + "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": "npm-run-all bundle:commonjs bundle:es2015 bundle:es2020", + "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", + "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..17f5f4eed --- /dev/null +++ b/packages/odata/src/index.ts @@ -0,0 +1,3 @@ +export { GridOdataService } from './services/grid-odata.service'; +export { OdataQueryBuilderService } from './services/odataQueryBuilder.service'; +export * from './interfaces/index'; 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..e7e253a82 --- /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 endswith(FirstName, '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: 'EndsWith', 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: 'EndsWith', 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 startswith(FirstName, '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..62bb58ec7 --- /dev/null +++ b/packages/odata/src/services/grid-odata.service.ts @@ -0,0 +1,615 @@ +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, + SingleColumnSort, + SlickGrid, +} 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: SlickGrid | undefined; + 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?.getOptions) ? this._grid.getOptions() : {}; + } + + constructor() { + this._odataService = new OdataQueryBuilderService(); + } + + init(serviceOptions: Partial, pagination?: Pagination, grid?: SlickGrid): 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: SingleColumnSort | MultiColumnSort) { + const sortColumns = (args.multiColumnSort) ? (args as MultiColumnSort).sortCols : new Array({ columnId: (args as ColumnSort).sortCol.id, 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' || operator === OperatorType.endsWith) ? '*' : ''; + 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 === '*' || operator === OperatorType.startsWith || operator === OperatorType.endsWith) { + // first/last character is a '*' will be a startsWith or endsWith + searchBy = (operator === '*' || operator === '*z' || operator === OperatorType.endsWith) ? `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; + }) as { columnId: string | number; sortAsc: boolean; }[] | 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) { + 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..14cd76e9c --- /dev/null +++ b/packages/odata/tsconfig.build.json @@ -0,0 +1,47 @@ +{ + "compilerOptions": { + "module": "esnext", + "moduleResolution": "node", + "target": "es2015", + "lib": [ + "es2020", + "dom" + ], + "types": [ + "moment", + "jest", + "jest-extended", + "jquery", + "node" + ], + "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..b5fab81ea --- /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": "commonjs", + "sourceMap": true, + "noImplicitReturns": true, + "lib": [ + "es2020", + "dom" + ], + "types": [ + "moment", + "jquery", + "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/vanilla-bundle/package.json b/packages/vanilla-bundle/package.json index 04bd8ffd1..5ee0ddfd5 100644 --- a/packages/vanilla-bundle/package.json +++ b/packages/vanilla-bundle/package.json @@ -6,6 +6,9 @@ "main": "dist/commonjs/index.js", "module": "dist/es2015/index.js", "typings": "dist/commonjs/index.d.ts", + "publishConfig": { + "access": "public" + }, "files": [ "src", "dist" @@ -17,9 +20,9 @@ "build": "cross-env tsc --build", "postbuild": "npm-run-all bundle:commonjs", "build:watch": "cross-env tsc --incremental --watch", + "bundle": "npm-run-all bundle:commonjs bundle:es2020 webpack:prod", "bundle:commonjs": "tsc --project tsconfig.build.json --outDir dist/commonjs --module commonjs", "bundle:es2020": "cross-env tsc --project tsconfig.build.json --outDir dist/es2020 --module es2015 --target es2020", - "bundle": "npm-run-all bundle:commonjs bundle:es2020 webpack:prod", "prebundle": "npm-run-all delete:dist delete:zip", "postbundle": "npm-run-all zip:dist", "delete:dist": "cross-env rimraf dist", @@ -43,13 +46,15 @@ "dependencies": { "@slickgrid-universal/common": "^0.0.2", "@slickgrid-universal/excel-export": "^0.0.2", - "@slickgrid-universal/file-export": "^0.0.2" + "@slickgrid-universal/file-export": "^0.0.2", + "dompurify": "^2.0.11" }, "devDependencies": { "@types/webpack": "^4.41.17", "archiver": "^4.0.1", "cross-env": "^7.0.2", "dts-bundle-webpack": "^1.0.2", + "html-loader": "^1.1.0", "npm-run-all": "^4.1.5", "rimraf": "^3.0.2", "ts-loader": "^7.0.5", diff --git a/packages/vanilla-bundle/src/components/__tests__/slick-pagination-without-i18n.spec.ts b/packages/vanilla-bundle/src/components/__tests__/slick-pagination-without-i18n.spec.ts new file mode 100644 index 000000000..0624aab24 --- /dev/null +++ b/packages/vanilla-bundle/src/components/__tests__/slick-pagination-without-i18n.spec.ts @@ -0,0 +1,113 @@ + +import { PaginationService, SharedService, SlickGrid, GridOption, Locale } from '@slickgrid-universal/common'; +import { SlickPaginationComponent } from '../slick-pagination'; +import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; +import { EventPubSubService } from '../../services/eventPubSub.service'; + +function removeExtraSpaces(textS: string) { + return `${textS}`.replace(/\s{2,}/g, ''); +} + +const gridStub = { + getOptions: jest.fn(), + getUID: () => 'slickgrid_123456', + registerPlugin: jest.fn(), +} as unknown as SlickGrid; + +const mockLocales = { + TEXT_ITEMS_PER_PAGE: 'items per page', + TEXT_ITEMS: 'items', + TEXT_OF: 'of', + TEXT_PAGE: 'page' +} as Locale; + +const mockGridOptions = { enableTranslate: false } as GridOption; + +const mockFullPagination = { + pageCount: 19, + pageNumber: 2, + pageSize: 5, + pageSizes: [5, 10, 15, 20], + totalItems: 95, + dataFrom: 10, + dataTo: 15, +}; + +const paginationServiceStub = { + dataFrom: 10, + dataTo: 15, + pageNumber: 2, + pageCount: 19, + itemsPerPage: 5, + pageSize: 5, + totalItems: 95, + availablePageSizes: [5, 10, 15, 20], + pageInfoTotalItems: jest.fn(), + getFullPagination: jest.fn(), + goToFirstPage: jest.fn(), + goToLastPage: jest.fn(), + goToNextPage: jest.fn(), + goToPreviousPage: jest.fn(), + goToPageNumber: jest.fn(), + changeItemPerPage: jest.fn(), + dispose: jest.fn(), + init: jest.fn(), +} as unknown as PaginationService; +Object.defineProperty(paginationServiceStub, 'dataFrom', { get: jest.fn(() => mockFullPagination.dataFrom), set: jest.fn() }); +Object.defineProperty(paginationServiceStub, 'dataTo', { get: jest.fn(() => mockFullPagination.dataTo), set: jest.fn() }); +Object.defineProperty(paginationServiceStub, 'itemsPerPage', { get: jest.fn(() => mockFullPagination.pageSize), set: jest.fn() }); + +describe('Slick-Pagination Component', () => { + let component: SlickPaginationComponent; + let div: HTMLDivElement; + let eventPubSubService: EventPubSubService; + let sharedService: SharedService; + let translateService: TranslateServiceStub; + + beforeEach(() => { + jest.spyOn(SharedService.prototype, 'grid', 'get').mockReturnValue(gridStub); + jest.spyOn(paginationServiceStub, 'getFullPagination').mockReturnValue(mockFullPagination); + div = document.createElement('div'); + document.body.appendChild(div); + sharedService = new SharedService(); + eventPubSubService = new EventPubSubService(); + translateService = new TranslateServiceStub(); + }); + + describe('Integration Tests', () => { + afterEach(() => { + // clear all the spyOn mocks to not influence next test + jest.clearAllMocks(); + }); + + it('should throw an error when "enableTranslate" is set and I18N Service is not provided', (done) => { + try { + mockGridOptions.enableTranslate = true; + translateService = null; + jest.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(mockGridOptions); + + component = new SlickPaginationComponent(paginationServiceStub, eventPubSubService, sharedService, translateService); + component.renderPagination(div); + } catch (e) { + expect(e.toString()).toContain('[Slickgrid-Universal] requires a Translate Service to be installed and configured when the grid option "enableTranslate" is enabled.'); + done(); + } + }); + + it('should have defined locale and expect new text in the UI', () => { + mockGridOptions.locales = mockLocales; + jest.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(mockGridOptions); + + component = new SlickPaginationComponent(paginationServiceStub, eventPubSubService, sharedService, translateService); + component.renderPagination(div); + + const pageInfoFromTo = document.querySelector('.page-info-from-to'); + const pageInfoTotalItems = document.querySelector('.page-info-total-items'); + + expect(translateService.getCurrentLocale()).toBe('en'); + expect(removeExtraSpaces(pageInfoFromTo.innerHTML)).toBe('10-15of'); + expect(removeExtraSpaces(pageInfoTotalItems.innerHTML)).toBe('95items'); + component.dispose(); + }); + }); +}); diff --git a/packages/vanilla-bundle/src/components/__tests__/slick-pagination.spec.ts b/packages/vanilla-bundle/src/components/__tests__/slick-pagination.spec.ts new file mode 100644 index 000000000..e31fb92d9 --- /dev/null +++ b/packages/vanilla-bundle/src/components/__tests__/slick-pagination.spec.ts @@ -0,0 +1,266 @@ + +import { PaginationService, SharedService, SlickGrid, GridOption } from '@slickgrid-universal/common'; +import { SlickPaginationComponent } from '../slick-pagination'; +import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; +import { EventPubSubService } from '../../services/eventPubSub.service'; + +function removeExtraSpaces(textS: string) { + return `${textS}`.replace(/\s{2,}/g, ''); +} + +const gridStub = { + getOptions: jest.fn(), + getUID: () => 'slickgrid_123456', + registerPlugin: jest.fn(), +} as unknown as SlickGrid; + +const mockGridOptions = { enableTranslate: false } as GridOption; + +const mockFullPagination = { + pageCount: 19, + pageNumber: 2, + pageSize: 5, + pageSizes: [5, 10, 15, 20], + totalItems: 95, + dataFrom: 10, + dataTo: 15, +}; + +const paginationServiceStub = { + dataFrom: 10, + dataTo: 15, + pageNumber: 2, + pageCount: 19, + itemsPerPage: 5, + pageSize: 5, + totalItems: 95, + availablePageSizes: [5, 10, 15, 20], + pageInfoTotalItems: jest.fn(), + getFullPagination: jest.fn(), + goToFirstPage: jest.fn(), + goToLastPage: jest.fn(), + goToNextPage: jest.fn(), + goToPreviousPage: jest.fn(), + goToPageNumber: jest.fn(), + changeItemPerPage: jest.fn(), + dispose: jest.fn(), + init: jest.fn(), +} as unknown as PaginationService; +Object.defineProperty(paginationServiceStub, 'dataFrom', { get: jest.fn(() => mockFullPagination.dataFrom), set: jest.fn() }); +Object.defineProperty(paginationServiceStub, 'dataTo', { get: jest.fn(() => mockFullPagination.dataTo), set: jest.fn() }); +Object.defineProperty(paginationServiceStub, 'pageCount', { get: jest.fn(() => mockFullPagination.pageCount), set: jest.fn() }); +Object.defineProperty(paginationServiceStub, 'pageNumber', { get: jest.fn(() => mockFullPagination.pageNumber), set: jest.fn() }); +Object.defineProperty(paginationServiceStub, 'itemsPerPage', { get: jest.fn(() => mockFullPagination.pageSize), set: jest.fn() }); +Object.defineProperty(paginationServiceStub, 'totalItems', { get: jest.fn(() => mockFullPagination.totalItems), set: jest.fn() }); + +describe('Slick-Pagination Component', () => { + let component: SlickPaginationComponent; + let div: HTMLDivElement; + let eventPubSubService: EventPubSubService; + let sharedService: SharedService; + let translateService: TranslateServiceStub; + + beforeEach(() => { + jest.spyOn(SharedService.prototype, 'grid', 'get').mockReturnValue(gridStub); + jest.spyOn(paginationServiceStub, 'getFullPagination').mockReturnValue(mockFullPagination); + div = document.createElement('div'); + document.body.appendChild(div); + sharedService = new SharedService(); + eventPubSubService = new EventPubSubService(); + translateService = new TranslateServiceStub(); + + component = new SlickPaginationComponent(paginationServiceStub, eventPubSubService, sharedService, translateService); + component.renderPagination(div); + }); + + describe('Integration Tests', () => { + afterEach(() => { + // clear all the spyOn mocks to not influence next test + jest.clearAllMocks(); + component.dispose(); + }); + + it('should make sure Slick-Pagination is defined', () => { + expect(component).toBeTruthy(); + // expect(paginationTemplate).toBeTruthy(); + expect(component.constructor).toBeDefined(); + }); + + it('should create a the Slick-Pagination component in the DOM', () => { + const pageInfoFromTo = document.querySelector('.page-info-from-to'); + const pageInfoTotalItems = document.querySelector('.page-info-total-items'); + const itemsPerPage = document.querySelector('.items-per-page'); + + expect(translateService.getCurrentLocale()).toBe('en'); + expect(removeExtraSpaces(pageInfoFromTo.innerHTML)).toBe('10-15of'); + expect(removeExtraSpaces(pageInfoTotalItems.innerHTML)).toBe('95items'); + expect(itemsPerPage.selectedOptions[0].value).toBe('5'); + }); + + it('should call changeToFirstPage() from the View and expect the pagination service to be called with correct method', () => { + const spy = jest.spyOn(paginationServiceStub, 'goToFirstPage'); + + const button = document.querySelector('.icon-seek-first'); + button.click(); + mockFullPagination.pageNumber = 1; + mockFullPagination.dataFrom = 1; + mockFullPagination.dataTo = 10; + jest.spyOn(paginationServiceStub, 'dataFrom', 'get').mockReturnValue(mockFullPagination.dataFrom); + jest.spyOn(paginationServiceStub, 'dataTo', 'get').mockReturnValue(mockFullPagination.dataTo); + + const input = document.querySelector('input.form-control'); + const itemFrom = document.querySelector('.item-from'); + const itemTo = document.querySelector('.item-to'); + + expect(spy).toHaveBeenCalled(); + expect(input.value).toBe('1'); + expect(component.dataFrom).toBe(1); + expect(component.dataTo).toBe(10); + expect(component.itemsPerPage).toBe(5); + expect(itemFrom.textContent).toBe('1'); + expect(itemTo.textContent).toBe('10'); + }); + + it('should call changeToNextPage() from the View and expect the pagination service to be called with correct method', () => { + const spy = jest.spyOn(paginationServiceStub, 'goToNextPage'); + + const button = document.querySelector('.icon-seek-next'); + button.click(); + + expect(spy).toHaveBeenCalled(); + }); + + it('should call changeToPreviousPage() from the View and expect the pagination service to be called with correct method', () => { + mockFullPagination.pageNumber = 2; + const spy = jest.spyOn(paginationServiceStub, 'goToPreviousPage'); + + const button = document.querySelector('.icon-seek-prev'); + button.click(); + + expect(spy).toHaveBeenCalled(); + }); + + it('should call changeToLastPage() from the View and expect the pagination service to be called with correct method', () => { + const spy = jest.spyOn(paginationServiceStub, 'goToLastPage'); + + const button = document.querySelector('.icon-seek-end'); + button.click(); + + expect(spy).toHaveBeenCalled(); + }); + + it('should change the page number and expect the pagination service to go to that page', () => { + const spy = jest.spyOn(paginationServiceStub, 'goToPageNumber'); + + const newPageNumber = 3; + const input = document.querySelector('input.form-control'); + input.value = `${newPageNumber}`; + const mockEvent = new CustomEvent('change', { bubbles: true, detail: { target: { value: newPageNumber } } }); + input.dispatchEvent(mockEvent); + component.pageNumber = newPageNumber; + + expect(spy).toHaveBeenCalledWith(newPageNumber); + }); + + it('should change the changeItemPerPage select dropdown and expect the pagination service call a change', () => { + const spy = jest.spyOn(paginationServiceStub, 'changeItemPerPage'); + + const newItemsPerPage = 10; + const select = document.querySelector('select'); + select.value = `${newItemsPerPage}`; + const mockEvent = new CustomEvent('change', { bubbles: true, detail: { target: { value: newItemsPerPage } } }); + select.dispatchEvent(mockEvent); + + expect(spy).toHaveBeenCalledWith(newItemsPerPage); + }); + + it(`should trigger "onPaginationRefreshed" and expect page from/to being displayed when total items is over 0 and also expect first/prev buttons to be disabled when on page 1`, () => { + mockFullPagination.pageNumber = 1; + mockFullPagination.totalItems = 100; + component.pageNumber = 1; + eventPubSubService.publish('onPaginationRefreshed', mockFullPagination); + const pageFromToElm = document.querySelector('span.page-info-from-to'); + + expect(component.firstButtonClasses).toBe('page-item seek-first disabled'); + expect(component.prevButtonClasses).toBe('page-item seek-prev disabled'); + expect(component.lastButtonClasses).toBe('page-item seek-end'); + expect(component.nextButtonClasses).toBe('page-item seek-next'); + expect(pageFromToElm.style.display).toBe(''); + }); + + it(`should trigger "onPaginationRefreshed" and expect page from/to being displayed when total items is over 0 and also expect last/next buttons to be disabled when on last page`, () => { + mockFullPagination.pageNumber = 10; + mockFullPagination.pageCount = 10; + mockFullPagination.totalItems = 100; + component.pageNumber = 10; + eventPubSubService.publish('onPaginationRefreshed', mockFullPagination); + const pageFromToElm = document.querySelector('span.page-info-from-to'); + + expect(component.firstButtonClasses).toBe('page-item seek-first'); + expect(component.prevButtonClasses).toBe('page-item seek-prev'); + expect(component.lastButtonClasses).toBe('page-item seek-end disabled'); + expect(component.nextButtonClasses).toBe('page-item seek-next disabled'); + expect(pageFromToElm.style.display).toBe(''); + }); + + it(`should trigger "onPaginationRefreshed" and expect page from/to NOT being displayed when total items is 0 and also expect all page buttons to be disabled`, () => { + mockFullPagination.pageNumber = 0; + mockFullPagination.totalItems = 0; + component.pageNumber = 0; + eventPubSubService.publish('onPaginationRefreshed', mockFullPagination); + const pageFromToElm = document.querySelector('span.page-info-from-to'); + + expect(component.firstButtonClasses).toBe('page-item seek-first disabled'); + expect(component.prevButtonClasses).toBe('page-item seek-prev disabled'); + expect(component.lastButtonClasses).toBe('page-item seek-end disabled'); + expect(component.nextButtonClasses).toBe('page-item seek-next disabled'); + expect(pageFromToElm.style.display).toBe('none'); + }); + }); +}); + +describe('with different i18n locale', () => { + let component: any; + let div: HTMLDivElement; + let eventPubSubService: EventPubSubService; + let sharedService: SharedService; + let translateService: TranslateServiceStub; + const mockFullPagination = { + pageCount: 19, + pageNumber: 2, + pageSize: 5, + pageSizes: [5, 10, 15, 20], + totalItems: 95, + dataFrom: 10, + dataTo: 15, + }; + + beforeEach(() => { + mockGridOptions.enableTranslate = true; + jest.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(mockGridOptions); + jest.spyOn(SharedService.prototype, 'grid', 'get').mockReturnValue(gridStub); + jest.spyOn(paginationServiceStub, 'getFullPagination').mockReturnValue(mockFullPagination); + div = document.createElement('div'); + document.body.appendChild(div); + sharedService = new SharedService(); + eventPubSubService = new EventPubSubService(); + translateService = new TranslateServiceStub(); + + component = new SlickPaginationComponent(paginationServiceStub, eventPubSubService, sharedService, translateService); + component.renderPagination(div); + }); + + it('should create a the Slick-Pagination component in the DOM and expect different locale when changed', (done) => { + translateService.setLocale('fr'); + eventPubSubService.publish('onLocaleChanged', 'fr'); + + setTimeout(() => { + const pageInfoFromTo = document.querySelector('.page-info-from-to'); + const pageInfoTotalItems = document.querySelector('.page-info-total-items'); + expect(translateService.getCurrentLocale()).toBe('fr'); + expect(removeExtraSpaces(pageInfoFromTo.innerHTML)).toBe(`10-15de`); + expect(removeExtraSpaces(pageInfoTotalItems.innerHTML)).toBe(`95éléments`); + done(); + }, 50); + }); +}); diff --git a/packages/vanilla-bundle/src/components/slick-pagination.html b/packages/vanilla-bundle/src/components/slick-pagination.html new file mode 100644 index 000000000..db9b0b206 --- /dev/null +++ b/packages/vanilla-bundle/src/components/slick-pagination.html @@ -0,0 +1,48 @@ +
+
+
+ + +
+ Page + + of +
+ + +
+ + + items per page, + + + - + of + + + + items + + + +
+
diff --git a/packages/vanilla-bundle/src/components/slick-pagination.ts b/packages/vanilla-bundle/src/components/slick-pagination.ts new file mode 100644 index 000000000..0ef317c0b --- /dev/null +++ b/packages/vanilla-bundle/src/components/slick-pagination.ts @@ -0,0 +1,247 @@ +import { + Constants, + getTranslationPrefix, + GridOption, + Locale, + PaginationService, + SharedService, + SlickGrid, + ServicePagination, + TranslaterService, + Subscription, + PubSubService, +} from '@slickgrid-universal/common'; +import { BindingHelper } from '../services/binding.helper'; + +export class SlickPaginationComponent { + private _bindingHelper: BindingHelper; + private _paginationElement: HTMLDivElement; + private _enableTranslate = false; + private _locales: Locale; + private _subscriptions: Subscription[] = []; + currentPagination: ServicePagination; + firstButtonClasses = ''; + lastButtonClasses = ''; + prevButtonClasses = ''; + nextButtonClasses = ''; + + // text translations (handled by i18n or by custom locale) + textItemsPerPage = 'items per page'; + textItems = 'items'; + textOf = 'of'; + textPage = 'Page'; + + constructor(private paginationService: PaginationService, private pubSubService: PubSubService, private sharedService: SharedService, private translaterService?: TranslaterService) { + this._bindingHelper = new BindingHelper(); + this._bindingHelper.querySelectorPrefix = `.${this.gridUid} `; + + this.currentPagination = this.paginationService.getFullPagination(); + this._enableTranslate = this.gridOptions && this.gridOptions.enableTranslate || false; + this._locales = this.gridOptions && this.gridOptions.locales || Constants.locales; + + if (this._enableTranslate && (!this.translaterService || !this.translaterService.translate)) { + throw new Error('[Slickgrid-Universal] requires a Translate Service to be installed and configured when the grid option "enableTranslate" is enabled.'); + } + this.translatePaginationTexts(this._locales); + + if (this._enableTranslate && this.pubSubService && this.pubSubService.subscribe) { + this._subscriptions.push( + this.pubSubService.subscribe('onLocaleChanged', () => this.translatePaginationTexts(this._locales)) + ); + } + + // Anytime the pagination is initialized or has changes, + // we'll copy the data into a local object so that we can add binding to this local object + this._subscriptions.push( + this.pubSubService.subscribe('onPaginationRefreshed', (paginationChanges: ServicePagination) => { + for (const key of Object.keys(paginationChanges)) { + this.currentPagination[key] = paginationChanges[key]; + } + this.updatePageButtonsUsability(); + const pageFromToElm = document.querySelector(`.${this.gridUid} span.page-info-from-to`); + if (pageFromToElm?.style) { + pageFromToElm.style.display = (this.currentPagination.totalItems === 0) ? 'none' : ''; + } + }) + ); + } + + get availablePageSizes(): number[] { + return this.paginationService.availablePageSizes || []; + } + + get dataFrom(): number { + return this.paginationService.dataFrom; + } + + get dataTo(): number { + return this.paginationService.dataTo; + } + + get itemsPerPage(): number { + return this.paginationService.itemsPerPage; + } + set itemsPerPage(count: number) { + this.paginationService.changeItemPerPage(count); + } + + get pageCount(): number { + return this.paginationService.pageCount; + } + + get pageNumber(): number { + return this.paginationService.pageNumber; + } + set pageNumber(page: number) { + // the setter has to be declared but we won't use it, instead we will use the "changeToCurrentPage()" to only update the value after ENTER keydown event + } + + get grid(): SlickGrid { + return this.sharedService.grid; + } + + get gridOptions(): GridOption { + return this.sharedService.gridOptions; + } + + get gridUid(): string { + return this.grid?.getUID() ?? ''; + } + + get totalItems() { + return this.paginationService.totalItems; + } + + get isLeftPaginationDisabled(): boolean { + return this.pageNumber === 1 || this.totalItems === 0; + } + + get isRightPaginationDisabled(): boolean { + return this.pageNumber === this.pageCount || this.totalItems === 0; + } + + dispose() { + this.paginationService.dispose(); + this._bindingHelper.dispose(); + this._paginationElement.remove(); + + // also dispose of all Subscriptions + this.pubSubService.unsubscribeAll(this._subscriptions); + } + + renderPagination(gridParentContainerElm: HTMLElement) { + const paginationTemplate = require('./slick-pagination.html'); + + if (paginationTemplate) { + const temp = document.createElement('div'); + temp.innerHTML = paginationTemplate; + this._paginationElement = temp.firstChild as HTMLDivElement; + this._paginationElement.classList.add(this.gridUid, 'pager'); + + if (gridParentContainerElm?.append && this._paginationElement) { + gridParentContainerElm.append(this._paginationElement); + this.renderPageSizes(); + this.addBindings(); + this.addEventListeners(); + this.updatePageButtonsUsability(); + } + } + } + + /** Render and fill the Page Sizes - of - 199 -
- - -
- - - items per page, - - - - 1-5 - of - - - - 995 items - - - -
-
`; - } -} diff --git a/packages/vanilla-bundle/src/services/__tests__/binding.helper.spec.ts b/packages/vanilla-bundle/src/services/__tests__/binding.helper.spec.ts new file mode 100644 index 000000000..4f00b3cab --- /dev/null +++ b/packages/vanilla-bundle/src/services/__tests__/binding.helper.spec.ts @@ -0,0 +1,104 @@ +import { BindingHelper } from '../binding.helper'; + +describe('Binding Helper', () => { + let div: HTMLDivElement; + let helper: BindingHelper; + + beforeEach(() => { + div = document.createElement('div'); + document.body.appendChild(div); + helper = new BindingHelper(); + }); + + afterEach(() => { + div.remove(); + helper.dispose(); + jest.clearAllMocks(); + }); + + it('should add a binding for an input and call a value change and expect a mocked object to have the reflected value', () => { + const mockCallback = jest.fn(); + const mockObj = { name: 'John', age: 20 }; + const elm = document.createElement('input'); + elm.className = 'custom-class'; + div.appendChild(elm); + + helper.addElementBinding(mockObj, 'name', 'input.custom-class', 'value', 'change', mockCallback); + elm.value = 'Jane'; + const mockEvent = new CustomEvent('change', { bubbles: true, detail: { target: { value: 'Jane' } } }); + elm.dispatchEvent(mockEvent); + + expect(helper.querySelectorPrefix).toBe(''); + expect(mockObj.name).toBe('Jane'); + expect(mockCallback).toHaveBeenCalled(); + }); + + it('should add a binding for a mocked object and expect the value to be reflected in the element input value', () => { + const mockObj = { name: 'John', age: 20 }; + const elm = document.createElement('input'); + elm.className = 'custom-class'; + div.appendChild(elm); + + helper.addElementBinding(mockObj, 'name', 'input.custom-class', 'value', 'change'); + mockObj.name = 'Jane'; + + expect(helper.querySelectorPrefix).toBe(''); + expect(elm.value).toBe('Jane'); + }); + + it('should add a binding for an span with multiple events change/blur and expect a mocked object to have the reflected value', () => { + const mockObj = { name: 'John', age: 20 }; + const elm = document.createElement('input'); + elm.className = 'custom-class'; + div.appendChild(elm); + + helper.addElementBinding(mockObj, 'name', 'input.custom-class', 'value', ['change', 'blur']); + elm.value = 'Jane'; + const mockEvent1 = new CustomEvent('change', { bubbles: true, detail: { target: { value: 'Jane' } } }); + elm.dispatchEvent(mockEvent1); + + expect(helper.querySelectorPrefix).toBe(''); + expect(mockObj.name).toBe('Jane'); + + elm.value = 'Johnny'; + const mockEvent2 = new CustomEvent('blur', { bubbles: true, detail: { target: { value: 'Jane' } } }); + elm.dispatchEvent(mockEvent2); + + expect(mockObj.name).toBe('Johnny'); + }); + + it('should add a binding for a mocked object and expect the value to be reflected in multiple elements input values', () => { + const mockObj = { name: 'John', age: 20 }; + const elm1 = document.createElement('span'); + const elm2 = document.createElement('span'); + elm1.className = 'custom'; + elm2.className = 'custom'; + div.className = 'grid'; + div.appendChild(elm1); + div.appendChild(elm2); + + helper.querySelectorPrefix = '.grid '; + helper.addElementBinding(mockObj, 'age', 'span.custom', 'textContent'); + mockObj.age = 30; + + expect(helper.querySelectorPrefix).toBe('.grid '); + // expect(elm1.textContent).toBe('30'); // not sure why this doesn't work in unit test, however it works in real life, so we'll leave at that + expect(elm2.textContent).toBe('30'); + expect(document.querySelectorAll('.grid span.custom').length).toBe(2); + }); + + it('should add event handler and expect callback to be called', () => { + const mockCallback = jest.fn(); + const elm = document.createElement('button'); + elm.className = 'grid123 icon-first'; + div.appendChild(elm); + + helper.querySelectorPrefix = '.grid123'; + helper.bindEventHandler('.icon-first', 'click', mockCallback); + const mockEvent = new CustomEvent('click'); + elm.dispatchEvent(mockEvent); + + expect(helper.querySelectorPrefix).toBe('.grid123'); + expect(mockCallback).toHaveBeenCalled(); + }); +}); diff --git a/packages/vanilla-bundle/src/services/__tests__/binding.service.spec.ts b/packages/vanilla-bundle/src/services/__tests__/binding.service.spec.ts new file mode 100644 index 000000000..96178f993 --- /dev/null +++ b/packages/vanilla-bundle/src/services/__tests__/binding.service.spec.ts @@ -0,0 +1,78 @@ +import { BindingService } from '../binding.service'; + +describe('Binding Service', () => { + let div: HTMLDivElement; + let service: BindingService; + + beforeEach(() => { + div = document.createElement('div'); + document.body.appendChild(div); + }); + + afterEach(() => { + div.remove(); + jest.clearAllMocks(); + }); + + it('should add a binding for an input and call a value change and expect a mocked object to have the reflected value', () => { + const mockCallback = jest.fn(); + const mockObj = { name: 'John', age: 20 }; + const elm = document.createElement('input'); + elm.className = 'custom-class'; + div.appendChild(elm); + + service = new BindingService({ variable: mockObj, property: 'name' }); + service.bind(elm, 'value', 'change', mockCallback); + elm.value = 'Jane'; + const mockEvent = new CustomEvent('change', { bubbles: true, detail: { target: { value: 'Jane' } } }); + elm.dispatchEvent(mockEvent); + + expect(mockObj.name).toBe('Jane'); + expect(mockCallback).toHaveBeenCalled(); + }); + + it('should return same input value when object property is not found', () => { + const mockCallback = jest.fn(); + const mockObj = { name: 'John', age: 20 }; + const elm = document.createElement('input'); + elm.className = 'custom-class'; + div.appendChild(elm); + + service = new BindingService({ variable: mockObj, property: 'invalidProperty' }); + service.bind(elm, 'value', 'change', mockCallback); + elm.value = 'Jane'; + const mockEvent = new CustomEvent('change', { bubbles: true, detail: { target: { value: 'Jane' } } }); + elm.dispatchEvent(mockEvent); + + expect(mockObj.name).toBe('John'); + expect(mockCallback).toHaveBeenCalled(); + }); + + it('should add a binding for a mocked object and expect the value to be reflected in the element input value', () => { + const mockCallback = jest.fn(); + const mockObj = { name: 'John', age: 20 }; + const elm = document.createElement('input'); + elm.className = 'custom-class'; + div.appendChild(elm); + + service = new BindingService({ variable: mockObj, property: 'name' }); + service.bind(elm, 'value', 'change', mockCallback); + mockObj.name = 'Jane'; + + expect(elm.value).toBe('Jane'); + }); + + it('should unbind an event from an element', () => { + const mockElm = { removeEventListener: jest.fn() } as unknown as HTMLElement; + const mockCallback = jest.fn(); + const removeEventSpy = jest.spyOn(mockElm, 'removeEventListener'); + const mockObj = { name: 'John', age: 20 }; + const elm = document.createElement('input'); + div.appendChild(elm); + + service = new BindingService({ variable: mockObj, property: 'name' }); + service.unbind(mockElm, 'click', mockCallback, false); + + expect(removeEventSpy).toHaveBeenCalledWith('click', mockCallback, false); + }); +}); diff --git a/packages/vanilla-bundle/src/services/__tests__/eventPubSub.service.spec.ts b/packages/vanilla-bundle/src/services/__tests__/eventPubSub.service.spec.ts new file mode 100644 index 000000000..a17aa8591 --- /dev/null +++ b/packages/vanilla-bundle/src/services/__tests__/eventPubSub.service.spec.ts @@ -0,0 +1,195 @@ +import { EventPubSubService } from '../eventPubSub.service'; +import { EventNamingStyle } from '@slickgrid-universal/common'; + +describe('EventPubSub Service', () => { + let service: EventPubSubService; + let divContainer: HTMLDivElement; + + beforeEach(() => { + divContainer = document.createElement('div'); + service = new EventPubSubService(divContainer); + service.eventNamingStyle = EventNamingStyle.camelCase; + }); + + afterEach(() => { + service.unsubscribeAll(); + }); + + it('should create the service', () => { + expect(service).toBeTruthy(); + }); + + describe('publish method', () => { + afterEach(() => { + service.unsubscribeAll(); + }); + + it('should call publish method and expect "dispatchCustomEvent" and "getEventNameByNamingConvention" to be called', () => { + const dispatchSpy = jest.spyOn(service, 'dispatchCustomEvent'); + const getEventNameSpy = jest.spyOn(service, 'getEventNameByNamingConvention'); + + service.publish('onClick', { name: 'John' }); + + expect(getEventNameSpy).toHaveBeenCalledWith('onClick', ''); + expect(dispatchSpy).toHaveBeenCalledWith('onClick', { name: 'John' }, true, false); + }); + + it('should define a different event name styling and expect "dispatchCustomEvent" and "getEventNameByNamingConvention" to be called', () => { + const dispatchSpy = jest.spyOn(service, 'dispatchCustomEvent'); + const getEventNameSpy = jest.spyOn(service, 'getEventNameByNamingConvention'); + + service.eventNamingStyle = EventNamingStyle.lowerCase; + service.publish('onClick', { name: 'John' }); + + expect(getEventNameSpy).toHaveBeenCalledWith('onClick', ''); + expect(dispatchSpy).toHaveBeenCalledWith('onclick', { name: 'John' }, true, false); + }); + }); + + describe('subscribe method', () => { + afterEach(() => { + service.unsubscribeAll(); + }); + + it('should call subscribe method and expect "addEventListener" and "getEventNameByNamingConvention" to be called', () => { + const addEventSpy = jest.spyOn(divContainer, 'addEventListener'); + const getEventNameSpy = jest.spyOn(service, 'getEventNameByNamingConvention'); + const mockCallback = jest.fn(); + + service.subscribe('onClick', mockCallback); + divContainer.dispatchEvent(new CustomEvent('onClick', { detail: { name: 'John' } })); + + expect(getEventNameSpy).toHaveBeenCalledWith('onClick', ''); + expect(service.subscribedEventNames).toEqual(['onClick']); + expect(service.subscribedEvents.length).toBe(1); + expect(addEventSpy).toHaveBeenCalledWith('onClick', expect.any(Function)); + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith({ name: 'John' }); + }); + + it('should call subscribe method and expect "addEventListener" and "getEventNameByNamingConvention" to be called', () => { + const addEventSpy = jest.spyOn(divContainer, 'addEventListener'); + const getEventNameSpy = jest.spyOn(service, 'getEventNameByNamingConvention'); + const mockCallback = jest.fn(); + + service.eventNamingStyle = EventNamingStyle.kebabCase; + service.subscribe('onClick', mockCallback); + divContainer.dispatchEvent(new CustomEvent('on-click', { detail: { name: 'John' } })); + + expect(getEventNameSpy).toHaveBeenCalledWith('onClick', ''); + expect(service.subscribedEventNames).toEqual(['on-click']); + expect(service.subscribedEvents.length).toBe(1); + expect(addEventSpy).toHaveBeenCalledWith('on-click', expect.any(Function)); + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith({ name: 'John' }); + }); + }); + + describe('subscribeEvent method', () => { + afterEach(() => { + service.unsubscribeAll(); + }); + + it('should call subscribe method and expect "addEventListener" and "getEventNameByNamingConvention" to be called', () => { + const addEventSpy = jest.spyOn(divContainer, 'addEventListener'); + const getEventNameSpy = jest.spyOn(service, 'getEventNameByNamingConvention'); + const mockCallback = jest.fn(); + + service.eventNamingStyle = EventNamingStyle.lowerCaseWithoutOnPrefix; + service.subscribeEvent('click', mockCallback); + divContainer.dispatchEvent(new CustomEvent('click', { detail: { name: 'John' } })); + + expect(getEventNameSpy).toHaveBeenCalledWith('click', ''); + expect(service.subscribedEventNames).toEqual(['click']); + expect(service.subscribedEvents.length).toBe(1); + expect(addEventSpy).toHaveBeenCalledWith('click', expect.any(Function)); + expect(mockCallback).toHaveBeenCalledTimes(1); + // expect(mockCallback).toHaveBeenCalledWith({ detail: { name: 'John' } }); + }); + + it('should call subscribe method and expect "addEventListener" and "getEventNameByNamingConvention" to be called', () => { + const addEventSpy = jest.spyOn(divContainer, 'addEventListener'); + const getEventNameSpy = jest.spyOn(service, 'getEventNameByNamingConvention'); + const mockCallback = jest.fn(); + + service.eventNamingStyle = EventNamingStyle.kebabCase; + service.subscribeEvent('onClick', mockCallback); + divContainer.dispatchEvent(new CustomEvent('on-click', { composed: true, detail: { name: 'John' } })); + + expect(getEventNameSpy).toHaveBeenCalledWith('onClick', ''); + expect(service.subscribedEventNames).toEqual(['on-click']); + expect(service.subscribedEvents.length).toBe(1); + expect(addEventSpy).toHaveBeenCalledWith('on-click', expect.any(Function)); + expect(mockCallback).toHaveBeenCalledTimes(1); + // expect(mockCallback).toHaveBeenCalledWith({ detail: { name: 'John' } }); + }); + }); + + describe('unsubscribe & unsubscribeAll method', () => { + it('should unsubscribe an event with a listener', () => { + const removeEventSpy = jest.spyOn(divContainer, 'removeEventListener'); + const getEventNameSpy = jest.spyOn(service, 'getEventNameByNamingConvention'); + const mockCallback = jest.fn(); + + service.subscribe('onClick', mockCallback); + divContainer.dispatchEvent(new CustomEvent('onClick', { detail: { name: 'John' } })); + service.unsubscribe('onClick', mockCallback); + + expect(getEventNameSpy).toHaveBeenCalledWith('onClick', ''); + expect(service.subscribedEventNames).toEqual(['onClick']); + expect(service.subscribedEvents.length).toBe(1); + expect(removeEventSpy).toHaveBeenCalledWith('onClick', mockCallback); + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith({ name: 'John' }); + }); + + it('should unsubscribeAll events', () => { + const removeEventSpy = jest.spyOn(divContainer, 'removeEventListener'); + const getEventNameSpy = jest.spyOn(service, 'getEventNameByNamingConvention'); + const unsubscribeSpy = jest.spyOn(service, 'unsubscribe'); + const mockCallback = jest.fn(); + const mockDblCallback = jest.fn(); + + service.subscribe('onClick', mockCallback); + service.subscribe('onDblClick', mockDblCallback); + divContainer.dispatchEvent(new CustomEvent('onClick', { detail: { name: 'John' } })); + service.unsubscribeAll(); + + expect(getEventNameSpy).toHaveBeenCalledWith('onClick', ''); + expect(getEventNameSpy).toHaveBeenCalledWith('onDblClick', ''); + expect(service.subscribedEventNames).toEqual(['onClick', 'onDblClick']); + expect(service.subscribedEvents.length).toBe(2); + expect(removeEventSpy).toHaveBeenCalledWith('onClick', mockCallback); + expect(removeEventSpy).toHaveBeenCalledWith('onDblClick', mockDblCallback); + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(unsubscribeSpy).toHaveBeenCalledTimes(2); + expect(mockCallback).toHaveBeenCalledWith({ name: 'John' }); + }); + + it('should unsubscribe all PubSub by disposing all of them', () => { + const mockDispose1 = jest.fn(); + const mockDispose2 = jest.fn(); + const mockSubscription1 = { dispose: mockDispose1 }; + const mockSubscription2 = { dispose: mockDispose2 }; + const mockSubscriptions = [mockSubscription1, mockSubscription2]; + + service.unsubscribeAll(mockSubscriptions); + + expect(mockDispose1).toHaveBeenCalledTimes(1); + expect(mockDispose2).toHaveBeenCalledTimes(1); + }); + + it('should unsubscribe all PubSub by unsubscribing all of them', () => { + const mockUnsubscribe1 = jest.fn(); + const mockUnsubscribe2 = jest.fn(); + const mockSubscription1 = { unsubscribe: mockUnsubscribe1 }; + const mockSubscription2 = { unsubscribe: mockUnsubscribe2 }; + const mockSubscriptions = [mockSubscription1, mockSubscription2]; + + service.unsubscribeAll(mockSubscriptions); + + expect(mockUnsubscribe1).toHaveBeenCalledTimes(1); + expect(mockUnsubscribe2).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/vanilla-bundle/src/services/__tests__/fileExport.service.spec.ts b/packages/vanilla-bundle/src/services/__tests__/fileExport.service.spec.ts new file mode 100644 index 000000000..69356acf0 --- /dev/null +++ b/packages/vanilla-bundle/src/services/__tests__/fileExport.service.spec.ts @@ -0,0 +1,50 @@ +import { GridOption, SharedService, SlickGrid, SlickDataView, ExportOption, DelimiterType, FileType, PubSubService } from '@slickgrid-universal/common'; +import { FileExportService } from '../fileExport.service'; + +const mockGridOptions = { enableTranslate: false } as GridOption; + +// URL object is not supported in JSDOM, we can simply mock it +(global as any).URL.createObjectURL = jest.fn(); + +const dataViewStub = { + getGrouping: jest.fn(), + getItem: jest.fn(), + getLength: jest.fn(), + setGrouping: jest.fn(), +} as unknown as SlickDataView; + +const gridStub = { + getColumnIndex: jest.fn(), + getData: () => dataViewStub, + getOptions: () => mockGridOptions, + getColumns: jest.fn(), + getGrouping: jest.fn(), +} as unknown as SlickGrid; + +const pubSubServiceStub = { + publish: jest.fn(), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + unsubscribeAll: jest.fn(), +} as PubSubService; + +describe('FileExport Service', () => { + let service: FileExportService; + let sharedService: SharedService; + + beforeEach(() => { + sharedService = new SharedService(); + sharedService.internalPubSubService = pubSubServiceStub; + service = new FileExportService(); + }); + + it('should initialize the service', () => { + const spy = jest.spyOn(service, 'exportToFile'); + + service.init(gridStub, sharedService); + service.exportToFile({ exportWithFormatter: true, sanitizeDataExport: true }); + + expect(service).toBeTruthy(); + expect(spy).toHaveBeenCalledWith({ exportWithFormatter: true, sanitizeDataExport: true }); + }); +}); diff --git a/packages/vanilla-bundle/src/services/__tests__/index.spec.ts b/packages/vanilla-bundle/src/services/__tests__/index.spec.ts new file mode 100644 index 000000000..ea0d0cc7a --- /dev/null +++ b/packages/vanilla-bundle/src/services/__tests__/index.spec.ts @@ -0,0 +1,7 @@ +import * as services from '../index'; + +describe('Testing Vanilla Services entry point', () => { + it('should have multiple service entries defined', () => { + expect(services).toBeTruthy(); + }); +}); diff --git a/packages/vanilla-bundle/src/services/__tests__/translate.service.spec.ts b/packages/vanilla-bundle/src/services/__tests__/translate.service.spec.ts new file mode 100644 index 000000000..0ebad82ba --- /dev/null +++ b/packages/vanilla-bundle/src/services/__tests__/translate.service.spec.ts @@ -0,0 +1,24 @@ +import { TranslateService } from '../translate.service'; + +describe('Export Service', () => { + let service: TranslateService; + + beforeEach(() => { + service = new TranslateService(); + }); + + it('should return "en" when calling "getCurrentLocale" method', () => { + const output = service.getCurrentLocale(); + expect(output).toBe('en'); + }); + + it('should return a promise with same locale returned as the one passed as argument', async () => { + const output = await service.setLocale('fr'); + expect(output).toBe('fr'); + }); + + it('should return same translation as argument provided', () => { + const output = service.translate('HELLO'); + expect(output).toBe('HELLO'); + }); +}); diff --git a/packages/vanilla-bundle/src/services/binding.helper.ts b/packages/vanilla-bundle/src/services/binding.helper.ts new file mode 100644 index 000000000..d3f930653 --- /dev/null +++ b/packages/vanilla-bundle/src/services/binding.helper.ts @@ -0,0 +1,56 @@ +import { BindingService } from './binding.service'; + +export class BindingHelper { + private _observers: BindingService[] = []; + private _querySelectorPrefix = ''; + + get querySelectorPrefix(): string { + return this._querySelectorPrefix || ''; + } + set querySelectorPrefix(prefix: string) { + this._querySelectorPrefix = prefix; + } + + constructor() { } + + dispose() { + for (const observer of this._observers) { + for (const binding of observer.elementBindings) { + observer.unbind(binding.element, binding.event, binding.listener); + } + } + } + + addElementBinding(variable: any, property: string, selector: string, attribute: string, events?: string | string[], callback?: (val: any) => void) { + const elements = document.querySelectorAll(`${this.querySelectorPrefix}${selector}`); + + elements.forEach((elm) => { + if (elm) { + // before creating a new observer, first check if the variable already has an associated observer + // if we can't find an observer then we'll create a new one for it + let observer = this._observers.find((bind) => bind.property === variable); + if (!observer) { + observer = new BindingService({ variable, property }); + if (Array.isArray(events)) { + for (const eventName of events) { + observer.bind(elm, attribute, eventName, callback); + } + } else { + observer.bind(elm, attribute, events, callback); + } + this._observers.push(observer); + } + } + }); + } + + bindEventHandler(selector: string, eventName: string, callback: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) { + const elements = document.querySelectorAll(`${this.querySelectorPrefix}${selector}`); + + elements.forEach((elm) => { + if (elm?.addEventListener) { + elm.addEventListener(eventName, callback, options); + } + }); + } +} diff --git a/packages/vanilla-bundle/src/services/binding.service.ts b/packages/vanilla-bundle/src/services/binding.service.ts index e5b7cf489..8ed52231a 100644 --- a/packages/vanilla-bundle/src/services/binding.service.ts +++ b/packages/vanilla-bundle/src/services/binding.service.ts @@ -1,18 +1,44 @@ +import * as DOMPurify from 'dompurify'; + +interface Binding { + variable: any; + property: string; +} + +interface ElementBinding { + element: Element | null; + attribute: string; + event: string; + listener: (val: any) => any; +} + + +/** + * Create 2 way Bindings for any variable that are primitive or object types, when it's an object type it will watch for property changes + * The following 2 articles helped in building this service: + * 1- https://blog.jeremylikness.com/blog/client-side-javascript-databinding-without-a-framework/ + * 2- https://www.wintellect.com/data-binding-pure-javascript/ + */ export class BindingService { _value: any = null; - _binding: any; + _binding: Binding; _property: string; - elementBindings: any[]; + elementBindings: ElementBinding[] = []; - constructor(binding: { variable: any; property?: string; }) { + constructor(binding: Binding) { this._binding = binding; this._property = binding.property || ''; this.elementBindings = []; - if (binding.property && binding.variable.hasOwnProperty(binding.property)) { - this._value = binding.variable[binding.property]; + if (binding.property && binding.variable && (binding.variable.hasOwnProperty(binding.property) || binding.property in binding.variable)) { + this._value = typeof binding.variable[binding.property] === 'string' ? DOMPurify.sanitize(binding.variable[binding.property], {}) : binding.variable[binding.property]; } else { - this._value = binding.variable; + this._value = typeof binding.variable === 'string' ? DOMPurify.sanitize(binding.variable, {}) : binding.variable; } + + Object.defineProperty(binding.variable, binding.property, { + get: this.valueGetter.bind(this), + set: this.valueSetter.bind(this) + }); } get property() { @@ -24,36 +50,34 @@ export class BindingService { } valueSetter(val: any) { - this._value = val; - for (let i = 0; i < this.elementBindings.length; i++) { - const binding = this.elementBindings[i]; - binding.element[binding.attribute] = val; + this._value = typeof val === 'string' ? DOMPurify.sanitize(val, {}) : val; + if (Array.isArray(this.elementBindings)) { + for (const binding of this.elementBindings) { + if (binding?.element && binding?.attribute) { + binding.element[binding.attribute] = typeof val === 'string' ? DOMPurify.sanitize(val, {}) : val; + } + } } } /** * Add binding to an element by an object attribute and optionally on an event, we can do it in couple ways * 1- if there's no event provided, it will simply replace the DOM elemnt (by an attribute), for example an innerHTML - * 2- when an event is provided, we will replace the DOM elemnt (by an attribute) every time an event is triggered - * 2.1- we could also provide an extra callback method to execute when the event is triggered + * 2- when an event is provided, we will replace the DOM element (by an attribute) every time an event is triggered + * 2.1- we could also provide an extra callback method to execute when the event gets triggered */ bind(element: Element | null, attribute: string, eventName?: string, callback?: (val: any) => any) { - const binding = { - element, - attribute, - event: '', - }; + const binding: ElementBinding = { element, attribute, event: '', listener: () => { } }; if (element) { if (eventName) { element.addEventListener(eventName, () => { const elmValue = element[attribute]; this.valueSetter(elmValue); - if (this._binding.variable.hasOwnProperty(this._binding.property)) { + if (this._binding.variable.hasOwnProperty(this._binding.property) || this._binding.property in this._binding.variable) { this._binding.variable[this._binding.property] = this.valueGetter(); - } else { - this._binding.variable = this.valueGetter(); } + if (typeof callback === 'function') { return callback(this.valueGetter()); } @@ -61,15 +85,15 @@ export class BindingService { binding.event = eventName; } this.elementBindings.push(binding); - element[attribute] = this._value ?? null; + element[attribute] = typeof this._value === 'string' ? DOMPurify.sanitize(this._value, {}) : this._value; } return this; } /** Unbind (remove) an event from an element */ - unbind(element: Element | null, eventName: string, callback: () => void) { + unbind(element: Element | null, eventName: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions) { if (element) { - element.removeEventListener(eventName, callback); + element.removeEventListener(eventName, listener, options); } } } diff --git a/packages/vanilla-bundle/src/services/eventPubSub.service.ts b/packages/vanilla-bundle/src/services/eventPubSub.service.ts index f2ab90f7c..eec712c42 100644 --- a/packages/vanilla-bundle/src/services/eventPubSub.service.ts +++ b/packages/vanilla-bundle/src/services/eventPubSub.service.ts @@ -1,11 +1,24 @@ import { EventNamingStyle, PubSubService, Subscription, titleCase, toKebabCase } from '@slickgrid-universal/common'; +interface PubSubEvent { + name: string; + listener: (event: CustomEventInit) => void; +} + export class EventPubSubService implements PubSubService { private _elementSource: Element; - private _eventNames: string[] = []; + private _subscribedEvents: PubSubEvent[] = []; eventNamingStyle = EventNamingStyle.camelCase; + get subscribedEvents(): PubSubEvent[] { + return this._subscribedEvents; + } + + get subscribedEventNames(): string[] { + return this._subscribedEvents.map((pubSubEvent) => pubSubEvent.name); + } + constructor(elementSource?: Element) { // use the provided element // or create a "phantom DOM node" (a div element that is never rendered) to set up a custom event dispatching @@ -15,7 +28,6 @@ export class EventPubSubService implements PubSubService { publish(eventName: string, data?: T) { const eventNameByConvention = this.getEventNameByNamingConvention(eventName, ''); this.dispatchCustomEvent(eventNameByConvention, data, true, false); - this._eventNames.push(eventNameByConvention); } subscribe(eventName: string, callback: (data: T) => void): any { @@ -24,16 +36,18 @@ export class EventPubSubService implements PubSubService { // the event listener will return the data in the "event.detail", so we need to return its content to the final callback // basically we substitute the "data" with "event.detail" so that the user ends up with only the "data" result this._elementSource.addEventListener(eventNameByConvention, (event: CustomEventInit) => callback.call(null, event.detail)); + this._subscribedEvents.push({ name: eventNameByConvention, listener: callback }); } - subscribeEvent(eventName: string, callback: (event: CustomEventInit) => void): any | void { + subscribeEvent(eventName: string, listener: (event: CustomEventInit) => void): any | void { const eventNameByConvention = this.getEventNameByNamingConvention(eventName, ''); - this._elementSource.addEventListener(eventNameByConvention, callback); + this._elementSource.addEventListener(eventNameByConvention, listener); + this._subscribedEvents.push({ name: eventNameByConvention, listener }); } - unsubscribe(eventName: string, callback: (event: CustomEventInit) => void) { + unsubscribe(eventName: string, listener: (event: CustomEventInit) => void) { const eventNameByConvention = this.getEventNameByNamingConvention(eventName, ''); - this._elementSource.removeEventListener(eventNameByConvention, callback); + this._elementSource.removeEventListener(eventNameByConvention, listener); } unsubscribeAll(subscriptions?: Subscription[]) { @@ -46,8 +60,8 @@ export class EventPubSubService implements PubSubService { } } } else { - for (const eventName of this._eventNames) { - this.unsubscribe(eventName, () => { }); + for (const pubSubEvent of this._subscribedEvents) { + this.unsubscribe(pubSubEvent.name, pubSubEvent.listener); } } } diff --git a/packages/vanilla-bundle/src/services/index.ts b/packages/vanilla-bundle/src/services/index.ts index bf27cd492..e59dc8a7f 100644 --- a/packages/vanilla-bundle/src/services/index.ts +++ b/packages/vanilla-bundle/src/services/index.ts @@ -1,4 +1,5 @@ export * from './binding.service'; +export * from './binding.helper'; export * from './eventPubSub.service'; export * from './fileExport.service'; export * from './footer.service'; diff --git a/packages/vanilla-bundle/src/services/translate.service.ts b/packages/vanilla-bundle/src/services/translate.service.ts index 91fe47a85..cd564b638 100644 --- a/packages/vanilla-bundle/src/services/translate.service.ts +++ b/packages/vanilla-bundle/src/services/translate.service.ts @@ -1,15 +1,19 @@ import { TranslaterService } from '@slickgrid-universal/common'; export class TranslateService implements TranslaterService { + private _currentLocale = 'en'; + getCurrentLocale(): string { - return 'en'; + return this._currentLocale; } - setLocale(locale: string): Promise { - return new Promise((resolve) => resolve(locale)); + setLocale(locale: string): Promise { + this._currentLocale = locale; + return new Promise((resolve) => resolve(this._currentLocale)); } translate(translationKey: string): string { + // TODO implement translation with `translations = require(jsonFilePath)`, then use `translations[translationKey]` return translationKey; } } diff --git a/packages/vanilla-bundle/src/vanilla-grid-bundle.ts b/packages/vanilla-bundle/src/vanilla-grid-bundle.ts index 2bf9949ef..6344cbf51 100644 --- a/packages/vanilla-bundle/src/vanilla-grid-bundle.ts +++ b/packages/vanilla-bundle/src/vanilla-grid-bundle.ts @@ -19,6 +19,14 @@ import { SlickGroupItemMetadataProvider, SlickNamespace, TreeDataOption, + executeBackendProcessesCallback, + GridStateType, + BackendServiceOption, + onBackendError, + refreshBackendDataset, + Pagination, + Subscription, + ServicePagination, // extensions AutoTooltipExtension, @@ -62,25 +70,28 @@ import { FileExportService } from './services/fileExport.service'; import { TranslateService } from './services/translate.service'; import { EventPubSubService } from './services/eventPubSub.service'; import { FooterService } from './services/footer.service'; -import { PaginationRenderer } from './pagination.renderer'; +import { SlickPaginationComponent } from './components/slick-pagination'; import { SalesforceGlobalGridOptions } from './salesforce-global-grid-options'; // using external non-typed js libraries declare const Slick: SlickNamespace; -declare const $: any; const DATAGRID_FOOTER_HEIGHT = 20; +const DATAGRID_PAGINATION_HEIGHT = 35; export class VanillaGridBundle { private _columnDefinitions: Column[]; private _gridOptions: GridOption; private _dataset: any[]; - private _gridContainerElm: Element; + private _gridContainerElm: HTMLElement; + private _gridParentContainerElm: HTMLElement; private _hideHeaderRowAfterPageLoad = false; private _isDatasetInitialized = false; private _isGridInitialized = false; private _isLocalGrid = true; + private _isPaginationInitialized = false; private _eventHandler: SlickEventHandler = new Slick.EventHandler(); private _eventPubSubService: EventPubSubService; + private _paginationOptions: Pagination | undefined; private _slickgridInitialized = false; private _intervalId: NodeJS.Timeout; private _intervalExecutionCounter = 0; @@ -89,8 +100,15 @@ export class VanillaGridBundle { grid: SlickGrid; metrics: Metrics; customDataView = false; + paginationData: { + gridOptions: GridOption; + paginationService: PaginationService; + }; + totalItems = 0; groupItemMetadataProvider: SlickGroupItemMetadataProvider; resizerPlugin: SlickResizer; + subscriptions: Subscription[] = []; + showPagination = false; // extensions extensionUtility: ExtensionUtility; @@ -123,7 +141,7 @@ export class VanillaGridBundle { translateService: TranslateService; treeDataService: TreeDataService; - paginationRenderer: PaginationRenderer; + slickPagination: SlickPaginationComponent; gridClass: string; gridClassName: string; @@ -186,19 +204,36 @@ export class VanillaGridBundle { this._gridOptions = mergedOptions; } + get paginationOptions(): Pagination | undefined { + return this._paginationOptions; + } + set paginationOptions(options: Pagination | undefined) { + if (this._paginationOptions) { + this._paginationOptions = { ...this._paginationOptions, ...options }; + } else { + this._paginationOptions = options; + } + this.gridOptions.pagination = options; + this.paginationService.updateTotalItems(options?.totalItems || 0); + } + get gridUid(): string { return this.grid?.getUID() ?? ''; } - constructor(gridContainerElm: Element, columnDefs?: Column[], options?: GridOption, dataset?: any[], hierarchicalDataset?: any[]) { + constructor(gridParentContainerElm: HTMLElement, columnDefs?: Column[], options?: GridOption, dataset?: any[], hierarchicalDataset?: any[]) { // make sure that the grid container has the "slickgrid-container" css class exist since we use it for slickgrid styling - gridContainerElm.classList.add('slickgrid-container'); + gridParentContainerElm.classList.add('grid-pane'); + this._gridParentContainerElm = gridParentContainerElm as HTMLDivElement; + this._gridContainerElm = document.createElement('div') as HTMLDivElement; + this._gridContainerElm.classList.add('slickgrid-container'); + gridParentContainerElm.appendChild(this._gridContainerElm); this._dataset = []; this._columnDefinitions = columnDefs || []; this._gridOptions = this.mergeGridOptions(options || {}); const isDeepCopyDataOnPageLoadEnabled = !!(this._gridOptions && this._gridOptions.enableDeepCopyDatasetOnPageLoad); - this._eventPubSubService = new EventPubSubService(gridContainerElm); + this._eventPubSubService = new EventPubSubService(gridParentContainerElm); this._eventPubSubService.eventNamingStyle = this._gridOptions && this._gridOptions.eventNamingStyle || EventNamingStyle.camelCase; this.gridEventService = new GridEventService(); @@ -250,8 +285,8 @@ export class VanillaGridBundle { if (hierarchicalDataset) { this.sharedService.hierarchicalDataset = (isDeepCopyDataOnPageLoadEnabled ? $.extend(true, [], hierarchicalDataset) : hierarchicalDataset) || []; } - this.initialization(gridContainerElm); - if (!hierarchicalDataset) { + this.initialization(this._gridContainerElm); + if (!hierarchicalDataset && !this.gridOptions.backendServiceApi) { this.dataset = dataset || []; } } @@ -273,7 +308,7 @@ export class VanillaGridBundle { this.grid?.destroy(); } - async initialization(gridContainerElm: Element) { + async initialization(gridContainerElm: HTMLElement) { // create the slickgrid container and add it to the user's grid container this._gridContainerElm = gridContainerElm; @@ -284,6 +319,10 @@ export class VanillaGridBundle { this.sharedService.internalPubSubService = this._eventPubSubService; this._eventHandler = new Slick.EventHandler(); const dataviewInlineFilters = this._gridOptions?.dataView?.inlineFilters ?? false; + this._paginationOptions = this.gridOptions?.pagination; + + this.createBackendApiInternalPostProcessCallback(this._gridOptions); + if (!this.customDataView) { if (this._gridOptions.draggableGrouping || this._gridOptions.enableGrouping) { this.extensionUtility.loadExtensionDynamically(ExtensionName.groupItemMetaProvider); @@ -368,23 +407,20 @@ export class VanillaGridBundle { // user could show a custom footer with the data metrics (dataset length and last updated timestamp) const customFooterElm = this.footerService.optionallyShowCustomFooterWithMetrics(this.metrics); if (customFooterElm) { - $(customFooterElm).appendTo($(this._gridContainerElm).parent()); + $(customFooterElm).appendTo(this._gridParentContainerElm); } - // user could show pagination - // if (this._gridOptions.enablePagination) { - // this.paginationRenderer = new PaginationRenderer(); - // const paginationElm = this.paginationRenderer.renderPagination(); - // if (paginationElm) { - // $(paginationElm).appendTo($(this._gridContainerElm).parent()); - // } - // } - const fixedGridDimensions = (this._gridOptions?.gridHeight || this._gridOptions?.gridWidth) ? { height: this._gridOptions?.gridHeight, width: this._gridOptions?.gridWidth } : undefined; const autoResizeOptions = this._gridOptions?.autoResize ?? { bottomPadding: 0 }; if (autoResizeOptions && autoResizeOptions.bottomPadding !== undefined) { autoResizeOptions.bottomPadding += this._gridOptions?.customFooterOptions?.footerHeight ?? DATAGRID_FOOTER_HEIGHT; } + if (autoResizeOptions && autoResizeOptions.bottomPadding !== undefined && this._gridOptions.enablePagination) { + autoResizeOptions.bottomPadding += DATAGRID_PAGINATION_HEIGHT; + } + if (fixedGridDimensions?.width && this._gridParentContainerElm?.style) { + this._gridParentContainerElm.style.width = `${fixedGridDimensions.width}px`; + } this.resizerPlugin = new Slick.Plugins.Resizer(autoResizeOptions, fixedGridDimensions); this.grid.registerPlugin(this.resizerPlugin); if (this._gridOptions.enableAutoResize) { @@ -431,6 +467,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)) { @@ -441,15 +483,22 @@ export class VanillaGridBundle { } } - // Pagination Service - // this.paginationService.init(this.grid) + // after the DataView is created & updated execute some processes & dispatch some events + if (!this.customDataView) { + this.executeAfterDataviewCreated(this.gridOptions); + } + // TODO - add interface const slickerElementInstance = { // Slick Grid & DataView objects 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, @@ -480,7 +529,7 @@ export class VanillaGridBundle { // using jQuery extend to do a deep clone has an unwanted side on objects and pageSizes but ES6 spread has other worst side effects // so we will just overwrite the pageSizes when needed, this is the only one causing issues so far. // jQuery wrote this on their docs:: On a deep extend, Object and Array are extended, but object wrappers on primitive types such as String, Boolean, and Number are not. - if (gridOptions.enablePagination && gridOptions.pagination && Array.isArray(gridOptions.pagination.pageSizes)) { + if (options?.pagination && gridOptions.enablePagination && gridOptions.pagination && Array.isArray(gridOptions.pagination.pageSizes)) { options.pagination.pageSizes = gridOptions.pagination.pageSizes; } @@ -498,15 +547,41 @@ 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) { this.filterService.init(grid); // if user entered some Filter "presets", we need to reflect them all in the DOM - // if (gridOptions.presets && Array.isArray(gridOptions.presets.filters) && gridOptions.presets.filters.length > 0) { - // this.filterService.populateColumnFilterSearchTermPresets(gridOptions.presets.filters); - // } + if (gridOptions.presets && Array.isArray(gridOptions.presets.filters) && gridOptions.presets.filters.length > 0) { + this.filterService.populateColumnFilterSearchTermPresets(gridOptions.presets.filters); + } + // bind external filter (backend) unless specified to use the local one if (gridOptions.backendServiceApi && !gridOptions.backendServiceApi.useLocalFiltering) { this.filterService.bindBackendOnFilter(grid, dataView); @@ -515,6 +590,23 @@ export class VanillaGridBundle { } } + // if user entered some Columns "presets", we need to reflect them all in the grid + if (gridOptions.presets && Array.isArray(gridOptions.presets.columns) && gridOptions.presets.columns.length > 0) { + const gridColumns: Column[] = this.gridStateService.getAssociatedGridColumns(grid, gridOptions.presets.columns); + if (gridColumns && Array.isArray(gridColumns) && gridColumns.length > 0) { + // make sure that the checkbox selector is also visible if it is enabled + if (gridOptions.enableCheckboxSelector) { + const checkboxColumn = (Array.isArray(this._columnDefinitions) && this._columnDefinitions.length > 0) ? this._columnDefinitions[0] : null; + if (checkboxColumn && checkboxColumn.id === '_checkbox_selector' && gridColumns[0].id !== '_checkbox_selector') { + gridColumns.unshift(checkboxColumn); + } + } + + // finally set the new presets columns (including checkbox selector if need be) + grid.setColumns(gridColumns); + } + } + // bind external sorting (backend) when available or default onSort (dataView) if (gridOptions.enableSorting && !this.customDataView) { // bind external sorting (backend) unless specified to use the local one @@ -525,6 +617,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) { @@ -558,6 +659,8 @@ export class VanillaGridBundle { itemCount: args && args.current || 0, totalItemCount: Array.isArray(this.dataset) ? this.dataset.length : 0 }; + + // if custom footer is enabled, then we'll update its metrics if (this.footerService.showCustomFooter) { const itemCountElm = document.querySelector('.item-count'); const totalCountElm = document.querySelector('.total-count'); @@ -572,10 +675,9 @@ export class VanillaGridBundle { // without this, filtering data with local dataset will not always show correctly // also don't use "invalidateRows" since it destroys the entire row and as bad user experience when updating a row - // see commit: https://github.com/ghiscoding/slickgrid-universal/commit/bb62c0aa2314a5d61188ff005ccb564577f08805 if (gridOptions && gridOptions.enableFiltering && !gridOptions.enableRowDetailView) { const onRowsChangedHandler = dataView.onRowsChanged; - (this._eventHandler as SlickEventHandler>).subscribe(onRowsChangedHandler, (e, args) => { + (this._eventHandler as SlickEventHandler>).subscribe(onRowsChangedHandler, (_e, args) => { if (args && args.rows && Array.isArray(args.rows)) { args.rows.forEach((row) => grid.updateRow(row)); grid.render(); @@ -596,6 +698,93 @@ 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)); + } + }); + } + } + } + + executeAfterDataviewCreated(gridOptions: GridOption) { + // if user entered some Sort "presets", we need to reflect them all in the DOM + if (gridOptions.enableSorting) { + if (gridOptions.presets && Array.isArray(gridOptions.presets.sorters) && gridOptions.presets.sorters.length > 0) { + this.sortService.loadGridSorters(gridOptions.presets.sorters); + } + } + } + + /** + * On a Pagination changed, we will trigger a Grid State changed with the new pagination info + * Also if we use Row Selection or the Checkbox Selector, we need to reset any selection + */ + paginationChanged(pagination: ServicePagination) { + // console.log('pagination changed', pagination) + const isSyncGridSelectionEnabled = this.gridStateService && this.gridStateService.needToPreserveRowSelection() || false; + if (!isSyncGridSelectionEnabled && (this.gridOptions.enableRowSelection || this.gridOptions.enableCheckboxSelector)) { + this.grid.setSelectedRows([]); + } + const { pageNumber, pageSize } = pagination; + if (this.sharedService) { + if (pageSize !== undefined && pageNumber !== undefined) { + this.sharedService.currentPagination = { pageNumber, pageSize }; + } + } + this._eventPubSubService.publish('onGridStateChanged', { + change: { newValues: { pageNumber, pageSize }, type: GridStateType.pagination }, + gridState: this.gridStateService.getCurrentGridState() + }); + } + /** * When dataset changes, we need to refresh the entire grid UI & possibly resize it as well * @param dataset @@ -604,10 +793,10 @@ export class VanillaGridBundle { // local grid, check if we need to show the Pagination // if so then also check if there's any presets and finally initialize the PaginationService // a local grid with Pagination presets will potentially have a different total of items, we'll need to get it from the DataView and update our total - // if (this._gridOptions && this._gridOptions.enablePagination && this._isLocalGrid) { - // this.showPagination = true; - // this.loadLocalGridPagination(dataset); - // } + if (this._gridOptions && this._gridOptions.enablePagination && this._isLocalGrid) { + this.showPagination = true; + this.loadLocalGridPagination(dataset); + } if (Array.isArray(dataset) && this.grid && this.dataView && typeof this.dataView.setItems === 'function') { this.dataView.setItems(dataset, this._gridOptions.datasetIdPropertyName); @@ -632,25 +821,25 @@ 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; - // } - // } + 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.updateTotalItems(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) { @@ -691,6 +880,17 @@ export class VanillaGridBundle { return showing; } + /** + * Check if there's any Pagination Presets defined in the Grid Options, + * if there are then load them in the paginationOptions object + */ + setPaginationOptionsWhenPresetDefined(gridOptions: GridOption, paginationOptions: Pagination): Pagination { + if (gridOptions.presets && gridOptions.presets.pagination && gridOptions.pagination) { + paginationOptions.pageSize = gridOptions.presets.pagination.pageSize; + paginationOptions.pageNumber = gridOptions.presets.pagination.pageNumber; + } + return paginationOptions; + } /** * For convenience to the user, we provide the property "editor" as an Slickgrid-Universal editor complex object @@ -710,6 +910,86 @@ export class VanillaGridBundle { }); } + /** Initialize the Pagination Service once */ + private initializePaginationService(paginationOptions: Pagination) { + if (this.gridOptions) { + this.paginationData = { + gridOptions: this.gridOptions, + paginationService: this.paginationService, + }; + this.paginationService.totalItems = this.totalItems; + this.paginationService.init(this.grid, paginationOptions, this.backendServiceApi); + this.subscriptions.push( + this._eventPubSubService.subscribe('onPaginationChanged', (paginationChanges: ServicePagination) => this.paginationChanged(paginationChanges)), + this._eventPubSubService.subscribe('onPaginationVisibilityChanged', (visibility: { visible: boolean }) => { + this.showPagination = visibility && visibility.visible || false; + if (this.gridOptions && this.gridOptions.backendServiceApi) { + refreshBackendDataset(); + } + }) + ); + + // also initialize (render) the pagination component + if (this._gridOptions.enablePagination) { + this.slickPagination = new SlickPaginationComponent(this.paginationService, this._eventPubSubService, this.sharedService, this.translateService); + this.slickPagination.renderPagination(this._gridParentContainerElm); + } + + this._isPaginationInitialized = true; + } + } + + /** + * local grid, check if we need to show the Pagination + * if so then also check if there's any presets and finally initialize the PaginationService + * a local grid with Pagination presets will potentially have a different total of items, we'll need to get it from the DataView and update our total + */ + private loadLocalGridPagination(dataset?: any[]) { + if (this.gridOptions && this._paginationOptions) { + this.totalItems = Array.isArray(dataset) ? dataset.length : 0; + if (this._paginationOptions && this.dataView && this.dataView.getPagingInfo) { + const slickPagingInfo = this.dataView.getPagingInfo(); + if (slickPagingInfo && slickPagingInfo.hasOwnProperty('totalRows') && this._paginationOptions.totalItems !== slickPagingInfo.totalRows) { + this.totalItems = slickPagingInfo?.totalRows || 0; + } + } + this._paginationOptions.totalItems = this.totalItems; + const paginationOptions = this.setPaginationOptionsWhenPresetDefined(this.gridOptions, this._paginationOptions); + this.initializePaginationService(paginationOptions); + } + } + + /** Load any Row Selections into the DataView that were presets by the user */ + private loadRowSelectionPresetWhenExists() { + // if user entered some Row Selections "presets" + const presets = this.gridOptions && this.gridOptions.presets; + const selectionModel = this.grid && this.grid.getSelectionModel(); + const enableRowSelection = this.gridOptions && (this.gridOptions.enableCheckboxSelector || this.gridOptions.enableRowSelection); + if (enableRowSelection && selectionModel && presets && presets.rowSelection && (Array.isArray(presets.rowSelection.gridRowIndexes) || Array.isArray(presets.rowSelection.dataContextIds))) { + let dataContextIds = presets.rowSelection.dataContextIds; + let gridRowIndexes = presets.rowSelection.gridRowIndexes; + + // maps the IDs to the Grid Rows and vice versa, the "dataContextIds" has precedence over the other + if (Array.isArray(dataContextIds) && dataContextIds.length > 0) { + gridRowIndexes = this.dataView.mapIdsToRows(dataContextIds) || []; + } else if (Array.isArray(gridRowIndexes) && gridRowIndexes.length > 0) { + dataContextIds = this.dataView.mapRowsToIds(gridRowIndexes) || []; + } + this.gridStateService.selectedRowDataContextIds = dataContextIds; + + // change the selected rows except UNLESS it's a Local Grid with Pagination + // local Pagination uses the DataView and that also trigger a change/refresh + // and we don't want to trigger 2 Grid State changes just 1 + if ((this._isLocalGrid && !this.gridOptions.enablePagination) || !this._isLocalGrid) { + setTimeout(() => { + if (this.grid && Array.isArray(gridRowIndexes)) { + this.grid.setSelectedRows(gridRowIndexes); + } + }); + } + } + } + /** * Patch for SalesForce, some issues arise when having a grid inside a Tab and user clicks in a different Tab without waiting for the grid to be rendered * in ideal world, we would simply call a resize when user comes back to the Tab with the grid (tab focused) but this is an extra step and we might not always have this event available. diff --git a/packages/vanilla-bundle/webpack.config.js b/packages/vanilla-bundle/webpack.config.js index 0f22dbd46..0c07d928a 100644 --- a/packages/vanilla-bundle/webpack.config.js +++ b/packages/vanilla-bundle/webpack.config.js @@ -39,6 +39,7 @@ module.exports = ({ production } = {}) => ({ }, module: { rules: [ + { test: /\.html$/i, loader: 'html-loader' }, { test: /\.ts?$/, use: 'ts-loader', exclude: nodeModulesDir, }, ], }, 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 +