Example26 - OData with Infinite Scroll
+
+ Example27 - GraphQL with Infinite Scroll
+
diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example09.ts b/examples/vite-demo-vanilla-bundle/src/examples/example09.ts
index ea6930db9..37c31726f 100644
--- a/examples/vite-demo-vanilla-bundle/src/examples/example09.ts
+++ b/examples/vite-demo-vanilla-bundle/src/examples/example09.ts
@@ -100,7 +100,10 @@ export default class Example09 {
}
},
{ id: 'company', name: 'Company', field: 'company', filterable: true, sortable: true },
- { id: 'category_name', name: 'Category', field: 'category/name', filterable: true, sortable: true }
+ {
+ id: 'category_name', name: 'Category', field: 'category/name', filterable: true, sortable: true,
+ formatter: (row, cell, val, colDef, dataContext) => dataContext['category']?.['name'] || ''
+ }
];
this.gridOptions = {
@@ -190,7 +193,7 @@ export default class Example09 {
getCustomerCallback(data) {
// totalItems property needs to be filled for pagination to work correctly
- // however we need to force Aurelia to do a dirty check, doing a clone object will do just that
+ // however we need to force a dirty check, doing a clone object will do just that
let totalItemCount: number = data['totalRecordCount']; // you can use "totalRecordCount" or any name or "odata.count" when "enableCount" is set
if (this.isCountEnabled) {
totalItemCount = (this.odataVersion === 4) ? data['@odata.count'] : data['d']['__count'];
diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example15.ts b/examples/vite-demo-vanilla-bundle/src/examples/example15.ts
index 95b41222b..c7903089f 100644
--- a/examples/vite-demo-vanilla-bundle/src/examples/example15.ts
+++ b/examples/vite-demo-vanilla-bundle/src/examples/example15.ts
@@ -223,7 +223,7 @@ export default class Example15 {
getCustomerCallback(data) {
// totalItems property needs to be filled for pagination to work correctly
- // however we need to force Aurelia to do a dirty check, doing a clone object will do just that
+ // however we need to force a dirty check, doing a clone object will do just that
let countPropName = 'totalRecordCount'; // you can use "totalRecordCount" or any name or "odata.count" when "enableCount" is set
if (this.isCountEnabled) {
countPropName = (this.odataVersion === 4) ? '@odata.count' : 'odata.count';
diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example26.html b/examples/vite-demo-vanilla-bundle/src/examples/example26.html
index 6abc82bd8..01668bc01 100644
--- a/examples/vite-demo-vanilla-bundle/src/examples/example26.html
+++ b/examples/vite-demo-vanilla-bundle/src/examples/example26.html
@@ -1,5 +1,4 @@
-
Example 26 - OData (v4) Backend Service with Infinite Scroll
diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example26.ts b/examples/vite-demo-vanilla-bundle/src/examples/example26.ts
index 070a6df77..b9394c489 100644
--- a/examples/vite-demo-vanilla-bundle/src/examples/example26.ts
+++ b/examples/vite-demo-vanilla-bundle/src/examples/example26.ts
@@ -87,7 +87,10 @@ export default class Example26 {
}
},
{ id: 'company', name: 'Company', field: 'company', filterable: true, sortable: true },
- { id: 'category_name', name: 'Category', field: 'category/name', filterable: true, sortable: true }
+ {
+ id: 'category_name', name: 'Category', field: 'category/name', filterable: true, sortable: true,
+ formatter: (row, cell, val, colDef, dataContext) => dataContext['category']?.['name'] || ''
+ }
];
this.gridOptions = {
@@ -169,7 +172,7 @@ export default class Example26 {
getCustomerCallback(data) {
// totalItems property needs to be filled for pagination to work correctly
- // however we need to force Aurelia to do a dirty check, doing a clone object will do just that
+ // however we need to force a dirty check, doing a clone object will do just that
const totalItemCount: number = data['@odata.count'];
this.metricsTotalItemCount = totalItemCount;
@@ -204,7 +207,7 @@ export default class Example26 {
* This function is only here to mock a WebAPI call (since we are using a JSON file for the demo)
* in your case the getCustomer() should be a WebAPI function returning a Promise
*/
- getCustomerDataApiMock(query): Promise {
+ getCustomerDataApiMock(query: string): Promise {
this.errorStatusClass = 'hidden';
// the mock is returning a Promise, just like a WebAPI typically does
@@ -238,17 +241,17 @@ export default class Example26 {
const filterBy = param.substring('$filter='.length).replace('%20', ' ');
if (filterBy.includes('matchespattern')) {
const regex = new RegExp(`matchespattern\\(([a-zA-Z]+),\\s'${CARET_HTML_ESCAPED}(.*?)'\\)`, 'i');
- const filterMatch = filterBy.match(regex);
+ const filterMatch = filterBy.match(regex) || [];
const fieldName = filterMatch[1].trim();
columnFilters[fieldName] = { type: 'matchespattern', term: '^' + filterMatch[2].trim() };
}
if (filterBy.includes('contains')) {
- const filterMatch = filterBy.match(/contains\(([a-zA-Z/]+),\s?'(.*?)'/);
+ const filterMatch = filterBy.match(/contains\(([a-zA-Z/]+),\s?'(.*?)'/) || [];
const fieldName = filterMatch[1].trim();
columnFilters[fieldName] = { type: 'substring', term: filterMatch[2].trim() };
}
if (filterBy.includes('substringof')) {
- const filterMatch = filterBy.match(/substringof\('(.*?)',\s([a-zA-Z/]+)/);
+ const filterMatch = filterBy.match(/substringof\('(.*?)',\s([a-zA-Z/]+)/) || [];
const fieldName = filterMatch[2].trim();
columnFilters[fieldName] = { type: 'substring', term: filterMatch[1].trim() };
}
@@ -263,16 +266,16 @@ export default class Example26 {
}
}
if (filterBy.includes('startswith') && filterBy.includes('endswith')) {
- const filterStartMatch = filterBy.match(/startswith\(([a-zA-Z ]*),\s?'(.*?)'/);
- const filterEndMatch = filterBy.match(/endswith\(([a-zA-Z ]*),\s?'(.*?)'/);
+ const filterStartMatch = filterBy.match(/startswith\(([a-zA-Z ]*),\s?'(.*?)'/) || [];
+ const filterEndMatch = filterBy.match(/endswith\(([a-zA-Z ]*),\s?'(.*?)'/) || [];
const fieldName = filterStartMatch[1].trim();
columnFilters[fieldName] = { type: 'starts+ends', term: [filterStartMatch[2].trim(), filterEndMatch[2].trim()] };
} else if (filterBy.includes('startswith')) {
- const filterMatch = filterBy.match(/startswith\(([a-zA-Z ]*),\s?'(.*?)'/);
+ const filterMatch = filterBy.match(/startswith\(([a-zA-Z ]*),\s?'(.*?)'/) || [];
const fieldName = filterMatch[1].trim();
columnFilters[fieldName] = { type: 'starts', term: filterMatch[2].trim() };
} else if (filterBy.includes('endswith')) {
- const filterMatch = filterBy.match(/endswith\(([a-zA-Z ]*),\s?'(.*?)'/);
+ const filterMatch = filterBy.match(/endswith\(([a-zA-Z ]*),\s?'(.*?)'/) || [];
const fieldName = filterMatch[1].trim();
columnFilters[fieldName] = { type: 'ends', term: filterMatch[2].trim() };
}
diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example27.html b/examples/vite-demo-vanilla-bundle/src/examples/example27.html
new file mode 100644
index 000000000..a09b21adc
--- /dev/null
+++ b/examples/vite-demo-vanilla-bundle/src/examples/example27.html
@@ -0,0 +1,69 @@
+
+
+ Example 27 - GraphQL Backend Service with Infinite Scroll
+
+ (*) NO DATA SHOWN
+ - just change any of Filters/Sorting/Pages and look at the "GraphQL Query" changing.
+ Also note that the column Name has a filter with a custom %% operator that behaves like an SQL LIKE operator supporting % wildcards.
+ Depending on your configuration, your GraphQL Server might already support regex querying (e.g. Hasura _regex)
+ or you could add your own implementation (e.g. see this SO).
+
+
+
+
+
+
+
+
+
+
+ Locale:
+
+
+
+
+ Metrics:
+ —
+ of
+ items
+ All Data Loaded!!!
+
+
+
+
+
+
+
+
+ GraphQL Query:
+
+
+
+
+
+ Status:
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example27.scss b/examples/vite-demo-vanilla-bundle/src/examples/example27.scss
new file mode 100644
index 000000000..1cfcd4fc6
--- /dev/null
+++ b/examples/vite-demo-vanilla-bundle/src/examples/example27.scss
@@ -0,0 +1,8 @@
+.demo27 {
+ .tag-data {
+ display: none;
+ &.fully-loaded {
+ display: inline-flex;
+ }
+ }
+}
\ No newline at end of file
diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example27.ts b/examples/vite-demo-vanilla-bundle/src/examples/example27.ts
new file mode 100644
index 000000000..d857ec289
--- /dev/null
+++ b/examples/vite-demo-vanilla-bundle/src/examples/example27.ts
@@ -0,0 +1,362 @@
+import { format } from '@formkit/tempo';
+import {
+ type Column,
+ FieldType,
+ Filters,
+ type GridOption,
+ type OnRowCountChangedEventArgs,
+ OperatorType,
+ SortDirection,
+} from '@slickgrid-universal/common';
+import { BindingEventService } from '@slickgrid-universal/binding';
+import { GraphqlService, type GraphqlPaginatedResult, type GraphqlServiceApi, } from '@slickgrid-universal/graphql';
+import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle';
+import { type MultipleSelectOption } from 'multiple-select-vanilla';
+
+import { ExampleGridOptions } from './example-grid-options';
+import type { TranslateService } from '../translate.service';
+import CustomersData from './data/customers_100.json';
+import './example10.scss';
+import '../material-styles.scss';
+
+const GRAPHQL_QUERY_DATASET_NAME = 'users';
+const FAKE_SERVER_DELAY = 250;
+
+function unescapeAndLowerCase(val: string) {
+ return val.replace(/^"/, '').replace(/"$/, '').toLowerCase();
+}
+
+export default class Example27 {
+ private _bindingEventService: BindingEventService;
+ private _darkMode = false;
+ columnDefinitions: Column[];
+ gridOptions: GridOption;
+ dataset = [];
+ metricsEndTime = '';
+ metricsItemCount = 0;
+ metricsTotalItemCount = 0;
+ sgb: SlickVanillaGridBundle;
+ tagDataClass = 'tag is-primary tag-data';
+ jsonData: Array<{ id: number; name: string; gender: string; company: string; category: { id: number; name: string; }; }> = [];
+
+ graphqlQuery = '...';
+ processing = false;
+ selectedLanguage: string;
+ selectedLanguageFile: string;
+ status = '';
+ statusClass = 'is-success';
+ translateService: TranslateService;
+ serverWaitDelay = FAKE_SERVER_DELAY; // server simulation with default of 250ms but 50ms for Cypress tests
+
+ constructor() {
+ this._bindingEventService = new BindingEventService();
+ // get the Translate Service from the window object,
+ // it might be better with proper Dependency Injection but this project doesn't have any at this point
+ this.translateService = (window).TranslateService;
+ this.selectedLanguage = this.translateService.getCurrentLanguage();
+ this.selectedLanguageFile = `${this.selectedLanguage}.json`;
+ }
+
+ async attached() {
+ // read the JSON and create a fresh copy of the data that we are free to modify
+ this.jsonData = JSON.parse(JSON.stringify(CustomersData));
+
+ this.initializeGrid();
+ const gridContainerElm = document.querySelector(`.grid27`) as HTMLDivElement;
+
+ this.sgb = new Slicker.GridBundle(gridContainerElm, this.columnDefinitions, { ...ExampleGridOptions, ...this.gridOptions }, this.dataset);
+
+ // bind any of the grid events
+ this._bindingEventService.bind(gridContainerElm, 'onrowcountchanged', this.refreshMetrics.bind(this) as EventListener);
+ document.body.classList.add('material-theme');
+ }
+
+ dispose() {
+ this.sgb?.dispose();
+ this._bindingEventService.unbindAll();
+ document.body.classList.remove('material-theme');
+ document.body.setAttribute('data-theme', 'light');
+ document.querySelector('.demo-container')?.classList.remove('dark-mode');
+ }
+
+ initializeGrid() {
+ this.columnDefinitions = [
+ {
+ id: 'name', field: 'name', nameKey: 'NAME', width: 60, columnGroupKey: 'CUSTOMER_INFORMATION',
+ type: FieldType.string,
+ sortable: true,
+ filterable: true,
+ filter: {
+ model: Filters.compoundInput,
+ }
+ },
+ {
+ id: 'gender', field: 'gender', nameKey: 'GENDER', filterable: true, sortable: true, width: 60, columnGroupKey: 'CUSTOMER_INFORMATION',
+ filter: {
+ model: Filters.singleSelect,
+ collection: [{ value: '', label: '' }, { value: 'male', labelKey: 'MALE', }, { value: 'female', labelKey: 'FEMALE', }]
+ }
+ },
+ {
+ id: 'company', field: 'company', nameKey: 'COMPANY', width: 60, columnGroupKey: 'CUSTOMER_INFORMATION',
+ sortable: true,
+ filterable: true,
+ filter: {
+ model: Filters.multipleSelect,
+ collection: this.jsonData
+ .sort((a, b) => a.company < b.company ? -1 : 1)
+ .map(m => ({ value: m.company, label: m.company })),
+ filterOptions: {
+ filter: true // adds a filter on top of the multi-select dropdown
+ } as MultipleSelectOption
+ }
+ },
+ ];
+
+ this.gridOptions = {
+ enableAutoTooltip: true,
+ autoTooltipOptions: {
+ enableForHeaderCells: true
+ },
+ enableTranslate: true,
+ translater: this.translateService, // pass the TranslateService instance to the grid
+ enableAutoResize: false,
+ gridHeight: 275,
+ gridWidth: 900,
+ compoundOperatorAltTexts: {
+ // where '=' is any of the `OperatorString` type shown above
+ text: { 'Custom': { operatorAlt: '%%', descAlt: 'SQL Like' } },
+ },
+ enableFiltering: true,
+ enableCellNavigation: true,
+ multiColumnSort: false,
+ createPreHeaderPanel: true,
+ showPreHeaderPanel: true,
+ preHeaderPanelHeight: 28,
+ gridMenu: {
+ resizeOnShowHeaderRow: true,
+ },
+ backendServiceApi: {
+ // we need to disable default internalPostProcess so that we deal with either replacing full dataset or appending to it
+ disableInternalPostProcess: true,
+ service: new GraphqlService(),
+ options: {
+ datasetName: GRAPHQL_QUERY_DATASET_NAME, // the only REQUIRED property
+ addLocaleIntoQuery: true, // optionally add current locale into the query
+ extraQueryArguments: [{ // optionally add some extra query arguments as input query arguments
+ field: 'userId',
+ value: 123
+ }],
+ // enable infinite via Boolean OR via { fetchSize: number }
+ infiniteScroll: { fetchSize: 30 }, // or use true, in that case it would use default size of 25
+ },
+ // you can define the onInit callback OR enable the "executeProcessCommandOnInit" flag in the service init
+ // onInit: (query) => this.getCustomerApiCall(query),
+ preProcess: () => this.displaySpinner(true),
+ process: (query) => this.getCustomerApiCall(query),
+ postProcess: (result: GraphqlPaginatedResult) => {
+ this.metricsEndTime = format(new Date(), 'DD MMM, h:mm:ssa');
+ this.metricsTotalItemCount = result.data[GRAPHQL_QUERY_DATASET_NAME].totalCount || 0;
+ this.displaySpinner(false);
+ this.getCustomerCallback(result);
+ }
+ } as GraphqlServiceApi
+ };
+ }
+
+ clearAllFiltersAndSorts() {
+ if (this.sgb?.gridService) {
+ this.sgb.gridService.clearAllFiltersAndSorts();
+ }
+ }
+
+ displaySpinner(isProcessing) {
+ this.processing = isProcessing;
+ this.status = (isProcessing) ? 'loading...' : 'finished!!';
+ this.statusClass = (isProcessing) ? 'notification is-light is-warning' : 'notification is-light is-success';
+ }
+
+ getCustomerCallback(result) {
+ console.log('getCustomerCallback', result);
+ const { nodes, totalCount } = this.metricsTotalItemCount = result.data[GRAPHQL_QUERY_DATASET_NAME];
+ this.metricsTotalItemCount = totalCount;
+
+ // even if we're not showing pagination, it is still used behind the scene to fetch next set of data (next page basically)
+ // once pagination totalItems is filled, we can update the dataset
+ this.sgb.paginationOptions!.totalItems = totalCount;
+
+ // infinite scroll has an extra data property to determine if we hit an infinite scroll and there's still more data (in that case we need append data)
+ // or if we're on first data fetching (no scroll bottom ever occured yet)
+ if (!result.infiniteScrollBottomHit) {
+ // initial load not scroll hit yet, full dataset assignment
+ this.sgb.dataset = nodes;
+ } else {
+ // scroll hit, for better perf we can simply use the DataView directly for better perf (which is better compare to replacing the entire dataset)
+ this.sgb.dataView?.addItems(nodes);
+ }
+
+ // NOTE: you can get currently loaded item count via the `onRowCountChanged`slick event, see `refreshMetrics()` below
+ // OR you could also calculate it yourself or get it via: `this.sgb.dataView.getItemCount() === totalItemCount`
+ // console.log('is data fully loaded: ', this.sgb.dataView?.getItemCount() === totalItemCount);
+ }
+
+ /**
+ * Calling your GraphQL backend server should always return a Promise of type GraphqlPaginatedResult
+ *
+ * @param query
+ * @return Promise
+ */
+ getCustomerApiCall(query: string): Promise {
+ // in your case, you will call your WebAPI function (wich needs to return a Promise)
+ // for the demo purpose, we will call a mock WebAPI function
+ return this.getCustomerDataApiMock(query);
+ }
+
+ getCustomerDataApiMock(query: string): Promise {
+ let firstCount = 0;
+ let offset = 0;
+ let orderByField = '';
+ let orderByDir = '';
+ let filteredData = this.jsonData;
+
+ if (query.includes('first:')) {
+ const topMatch = query.match(/first:([0-9]+),/) || [];
+ firstCount = +topMatch[1];
+ }
+ if (query.includes('offset:')) {
+ const offsetMatch = query.match(/offset:([0-9]+),/) || [];
+ offset = +offsetMatch[1];
+ }
+ if (query.includes('orderBy:')) {
+ const [_, field, dir] = /orderBy:\[{field:([a-zA-Z/]+),direction:(ASC|DESC)}\]/gi.exec(query) || [];
+ orderByField = field || '';
+ orderByDir = dir || '';
+ }
+ if (query.includes('orderBy:')) {
+ const [_, field, dir] = /orderBy:\[{field:([a-zA-Z/]+),direction:(ASC|DESC)}\]/gi.exec(query) || [];
+ orderByField = field || '';
+ orderByDir = dir || '';
+ }
+ if (query.includes('filterBy:')) {
+ const regex = /{field:(\w+),operator:(\w+),value:([0-9a-z',"\s]*)}/gi;
+
+ // loop through all filters
+ let matches;
+ while ((matches = regex.exec(query)) !== null) {
+ const field = matches[1] || '';
+ const operator = matches[2] || '';
+ const value = matches[3] || '';
+
+ console.log('filterBy:', field, operator, value);
+ let [term1, term2] = value.split(',');
+
+ if (field && operator && value !== '') {
+ filteredData = filteredData.filter(dataContext => {
+ const dcVal = dataContext[field];
+ // remove any double quotes & lowercase the terms
+ term1 = unescapeAndLowerCase(term1);
+ term2 = unescapeAndLowerCase(term2 || '');
+
+ switch (operator) {
+ case 'EQ': return dcVal.toLowerCase() === term1;
+ case 'NE': return dcVal.toLowerCase() !== term1;
+ case 'LE': return dcVal.toLowerCase() <= term1;
+ case 'LT': return dcVal.toLowerCase() < term1;
+ case 'GT': return dcVal.toLowerCase() > term1;
+ case 'GE': return dcVal.toLowerCase() >= term1;
+ case 'EndsWith': return dcVal.toLowerCase().endsWith(term1);
+ case 'StartsWith': return dcVal.toLowerCase().startsWith(term1);
+ case 'Starts+Ends': return dcVal.toLowerCase().startsWith(term1) && dcVal.toLowerCase().endsWith(term2);
+ case 'Contains': return dcVal.toLowerCase().includes(term1);
+ case 'Not_Contains': return !dcVal.toLowerCase().includes(term1);
+ case 'IN':
+ const terms = value.toLocaleLowerCase().split(',');
+ for (const term of terms) {
+ if (dcVal.toLocaleLowerCase() === unescapeAndLowerCase(term)) {
+ return true;
+ }
+ }
+ break;
+ }
+ });
+ }
+ }
+ }
+
+ // make sure page skip is not out of boundaries, if so reset to first page & remove skip from query
+ let firstRow = offset;
+ if (firstRow > filteredData.length) {
+ query = query.replace(`offset:${firstRow}`, '');
+ firstRow = 0;
+ }
+
+ // sorting when defined
+ const selector = (obj: any) => orderByField ? obj[orderByField] : obj;
+ switch (orderByDir.toUpperCase()) {
+ case 'ASC':
+ filteredData = filteredData.sort((a, b) => selector(a).localeCompare(selector(b)));
+ break;
+ case 'DESC':
+ filteredData = filteredData.sort((a, b) => selector(b).localeCompare(selector(a)));
+ break;
+ }
+
+ // return data subset (page)
+ const updatedData = filteredData.slice(firstRow, firstRow + firstCount);
+
+
+ // in your case, you will call your WebAPI function (wich needs to return a Promise)
+ // for the demo purpose, we will call a mock WebAPI function
+ const mockedResult = {
+ // the dataset name is the only unknown property
+ // will be the same defined in your GraphQL Service init, in our case GRAPHQL_QUERY_DATASET_NAME
+ data: {
+ [GRAPHQL_QUERY_DATASET_NAME]: {
+ nodes: updatedData,
+ totalCount: filteredData.length,
+ },
+ },
+ };
+
+ return new Promise(resolve => {
+ setTimeout(() => {
+ this.graphqlQuery = this.gridOptions.backendServiceApi!.service.buildQuery();
+ resolve(mockedResult);
+ }, this.serverWaitDelay);
+ });
+ }
+
+ refreshMetrics(event: CustomEvent<{ args: OnRowCountChangedEventArgs; }>) {
+ const args = event?.detail?.args;
+ if (args?.current >= 0) {
+ this.metricsItemCount = this.sgb.dataset.length || 0;
+ this.tagDataClass = this.metricsItemCount === this.metricsTotalItemCount
+ ? 'tag is-primary tag-data fully-loaded'
+ : 'tag is-primary tag-data partial-load';
+ }
+ }
+
+ async switchLanguage() {
+ const nextLanguage = (this.selectedLanguage === 'en') ? 'fr' : 'en';
+ await this.translateService.use(nextLanguage);
+ this.selectedLanguage = nextLanguage;
+ this.selectedLanguageFile = `${this.selectedLanguage}.json`;
+ }
+
+ toggleDarkMode() {
+ this._darkMode = !this._darkMode;
+ this.toggleBodyBackground();
+ this.sgb.gridOptions = { ...this.sgb.gridOptions, darkMode: this._darkMode };
+ this.sgb.slickGrid?.setOptions({ darkMode: this._darkMode });
+ }
+
+ toggleBodyBackground() {
+ if (this._darkMode) {
+ document.body.setAttribute('data-theme', 'dark');
+ document.querySelector('.demo-container')?.classList.add('dark-mode');
+ } else {
+ document.body.setAttribute('data-theme', 'light');
+ document.querySelector('.demo-container')?.classList.remove('dark-mode');
+ }
+ }
+}
diff --git a/packages/common/src/interfaces/backendService.interface.ts b/packages/common/src/interfaces/backendService.interface.ts
index d78b35ee8..ce5a3aa36 100644
--- a/packages/common/src/interfaces/backendService.interface.ts
+++ b/packages/common/src/interfaces/backendService.interface.ts
@@ -25,7 +25,7 @@ export interface BackendService {
buildQuery: (serviceOptions?: BackendServiceOption) => string;
/** Allow to process/change the result */
- postProcess?: (processResult: unknown) => void;
+ postProcess?: (processResult: any) => void;
/** Clear all sorts */
clearFilters?: () => void;
diff --git a/packages/common/src/interfaces/backendServiceApi.interface.ts b/packages/common/src/interfaces/backendServiceApi.interface.ts
index ffc0cf1be..249dc669c 100644
--- a/packages/common/src/interfaces/backendServiceApi.interface.ts
+++ b/packages/common/src/interfaces/backendServiceApi.interface.ts
@@ -2,9 +2,12 @@ import type { Observable } from '../services/rxjsFacade';
import type { BackendService } from './backendService.interface';
export interface BackendServiceApi {
- /** How long to wait until we start querying backend to avoid sending too many requests to backend server. Default to 500ms */
+ /** Default to 500ms, how long to wait until we start querying backend to avoid sending too many requests to backend server. */
filterTypingDebounce?: number;
+ /** Do we want to disable the default creation of an internal post process callback (currently only available for GraphQL) */
+ disableInternalPostProcess?: boolean;
+
/** Backend Service Options */
options?: any;
@@ -31,6 +34,12 @@ export interface BackendServiceApi {
// available methods
// ------------------
+ /**
+ * INTERNAL USAGE ONLY by Slickgrid-Universal
+ * This internal process will be run just before postProcess and is meant to refresh the Dataset & Pagination after a GraphQL call
+ */
+ internalPostProcess?: (result: any) => void;
+
/** On error callback, when an error is thrown by the process execution */
onError?: (e: any) => void;
@@ -48,10 +57,4 @@ export interface BackendServiceApi {
/** After executing the query, what action to perform? For example, stop the spinner */
postProcess?: (response: any) => void;
-
- /**
- * INTERNAL USAGE ONLY by Slickgrid-Universal
- * This internal process will be run just before postProcess and is meant to refresh the Dataset & Pagination after a GraphQL call
- */
- internalPostProcess?: (result: any) => void;
}
diff --git a/packages/common/src/services/backendUtility.service.ts b/packages/common/src/services/backendUtility.service.ts
index 5b5bc6fb4..8e6e25561 100644
--- a/packages/common/src/services/backendUtility.service.ts
+++ b/packages/common/src/services/backendUtility.service.ts
@@ -46,7 +46,7 @@ export class BackendUtilityService {
if (backendApi.service.options?.infiniteScroll) {
processResult.infiniteScrollBottomHit = this._infiniteScrollBottomHit;
- processResult.itemCount = null; // our item count is unknown when using infinite scroll
+ delete processResult.itemCount; // our item count is unknown when using infinite scroll
}
}
backendApi.postProcess(processResult);
diff --git a/packages/graphql/src/interfaces/graphqlPaginatedResult.interface.ts b/packages/graphql/src/interfaces/graphqlPaginatedResult.interface.ts
index 89fa4eb57..fbeda1961 100644
--- a/packages/graphql/src/interfaces/graphqlPaginatedResult.interface.ts
+++ b/packages/graphql/src/interfaces/graphqlPaginatedResult.interface.ts
@@ -10,18 +10,22 @@ export interface GraphqlPaginatedResult {
totalCount: number;
// ---
- // When using a Cursor, we'll also have Edges and PageInfo according to a cursor position
+ // 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?: CursorPageInfo;
- }
+ };
};
+ /** when using Infinite Scroll, we'll want to know when we hit the bottom of the scroll to get next subset */
+ infiniteScrollBottomHit?: boolean;
+
/** Some metrics of the last executed query (startTime, endTime, executionTime, itemCount, totalItemCount) */
metrics?: Metrics;
}
diff --git a/packages/graphql/src/services/graphql.service.ts b/packages/graphql/src/services/graphql.service.ts
index 581a0f731..54387bddc 100644
--- a/packages/graphql/src/services/graphql.service.ts
+++ b/packages/graphql/src/services/graphql.service.ts
@@ -9,6 +9,7 @@ import type {
CurrentSorter,
FilterChangedArgs,
GridOption,
+ InfiniteScrollOption,
MultiColumnSort,
OperatorString,
Pagination,
@@ -25,6 +26,7 @@ import {
mapOperatorType,
mapOperatorByFieldType,
OperatorType,
+ SlickEventHandler,
SortDirection,
} from '@slickgrid-universal/common';
import { getHtmlStringOutput, stripTags } from '@slickgrid-universal/utils';
@@ -34,6 +36,7 @@ import type {
GraphqlCustomFilteringOption,
GraphqlDatasetFilter,
GraphqlFilteringOption,
+ GraphqlPaginatedResult,
GraphqlPaginationOption,
GraphqlServiceOption,
GraphqlSortingOption,
@@ -49,8 +52,10 @@ export class GraphqlService implements BackendService {
protected _currentPagination: CurrentPagination | null = null;
protected _currentSorters: CurrentSorter[] = [];
protected _columnDefinitions?: Column[] | undefined;
+ protected _eventHandler: SlickEventHandler;
protected _grid: SlickGrid | undefined;
protected _datasetIdPropName = 'id';
+ protected _scrollEndCalled = false;
options: GraphqlServiceOption | undefined;
pagination: Pagination | undefined;
defaultPaginationOptions: GraphqlPaginationOption = {
@@ -68,6 +73,10 @@ export class GraphqlService implements BackendService {
return this._grid?.getOptions() ?? {} as GridOption;
}
+ constructor() {
+ this._eventHandler = new SlickEventHandler();
+ }
+
/** Initialization of the service, which acts as a constructor */
init(serviceOptions?: GraphqlServiceOption, pagination?: Pagination, grid?: SlickGrid, sharedService?: SharedService): void {
this._grid = grid;
@@ -75,9 +84,33 @@ export class GraphqlService implements BackendService {
this.pagination = pagination;
this._datasetIdPropName = this._gridOptions.datasetIdPropertyName || 'id';
- if (grid?.getColumns) {
+ if (typeof grid?.getColumns === 'function') {
this._columnDefinitions = sharedService?.allColumns ?? grid.getColumns() ?? [];
}
+
+ if (grid && this.options.infiniteScroll) {
+ this._eventHandler.subscribe(grid.onScroll, (_e, args) => {
+ const viewportElm = args.grid.getViewportNode()!;
+ if (
+ this._gridOptions.backendServiceApi?.onScrollEnd
+ && ['mousewheel', 'scroll'].includes(args.triggeredBy || '')
+ && args.scrollTop > 0
+ && this.pagination?.totalItems
+ && Math.ceil(viewportElm.offsetHeight + args.scrollTop) >= args.scrollHeight
+ ) {
+ if (!this._scrollEndCalled) {
+ this._gridOptions.backendServiceApi.onScrollEnd();
+ this._scrollEndCalled = true;
+ }
+ }
+ });
+ }
+ }
+
+ /** Dispose the service */
+ dispose(): void {
+ // unsubscribe all SlickGrid events
+ this._eventHandler.unsubscribeAll();
}
/**
@@ -88,7 +121,6 @@ export class GraphqlService implements BackendService {
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);
@@ -99,7 +131,7 @@ export class GraphqlService implements BackendService {
// get all the columnds Ids for the filters to work
const columnIds: string[] = [];
- if (columnDefinitions && Array.isArray(columnDefinitions)) {
+ if (Array.isArray(columnDefinitions)) {
for (const column of columnDefinitions) {
if (!column.excludeFieldFromQuery) {
columnIds.push(column.field);
@@ -121,7 +153,7 @@ export class GraphqlService implements BackendService {
const columnsQuery = this.buildFilterQuery(columnIds);
let graphqlNodeFields = [];
- if (this._gridOptions.enablePagination !== false) {
+ if (this._gridOptions.enablePagination !== false || this.options.infiniteScroll) {
if (this.options.useCursor) {
// ...pageInfo { hasNextPage, endCursor }, edges { cursor, node { _columns_ } }, totalCount: 100
const edgesQb = new QueryBuilder('edges');
@@ -146,7 +178,7 @@ export class GraphqlService implements BackendService {
let datasetFilters: GraphqlDatasetFilter = {};
// only add pagination if it's enabled in the grid options
- if (this._gridOptions.enablePagination !== false) {
+ if (this._gridOptions.enablePagination !== false || this.options.infiniteScroll) {
datasetFilters = {};
if (this.options.useCursor && this.options.paginationOptions) {
@@ -154,7 +186,10 @@ export class GraphqlService implements BackendService {
}
else {
const paginationOptions = this.options?.paginationOptions;
- datasetFilters.first = this.options?.paginationOptions?.first ?? this.pagination?.pageSize ?? this.defaultPaginationOptions.first;
+ datasetFilters.first = (this.options?.infiniteScroll as InfiniteScrollOption)?.fetchSize
+ ?? this.options?.paginationOptions?.first
+ ?? this.pagination?.pageSize
+ ?? this.defaultPaginationOptions.first;
datasetFilters.offset = paginationOptions?.hasOwnProperty('offset') ? +(paginationOptions as any)['offset'] : 0;
}
}
@@ -187,6 +222,13 @@ export class GraphqlService implements BackendService {
return this.trimDoubleQuotesOnEnumField(queryQb.toString(), enumSearchProperties, this.options.keepArgumentFieldDoubleQuotes || false);
}
+ postProcess(processResult: GraphqlPaginatedResult): void {
+ this._scrollEndCalled = false;
+ if (processResult.data && this.pagination) {
+ this.pagination.totalItems = processResult.data[this.getDatasetName()]?.totalCount || 0;
+ }
+ }
+
/**
* 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
@@ -225,11 +267,11 @@ export class GraphqlService implements BackendService {
}
/**
- * Get an initialization of Pagination options
+ * Get default initial Pagination options
* @return Pagination Options
*/
getInitPaginationOptions(): GraphqlDatasetFilter {
- const paginationFirst = this.pagination ? this.pagination.pageSize : DEFAULT_ITEMS_PER_PAGE;
+ const paginationFirst = (this.options?.infiniteScroll as InfiniteScrollOption)?.fetchSize ?? this.pagination?.pageSize ?? DEFAULT_ITEMS_PER_PAGE;
return this.options?.useCursor
? { first: paginationFirst }
: { first: paginationFirst, offset: 0 };
@@ -276,7 +318,7 @@ export class GraphqlService implements BackendService {
};
// 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'))) {
+ if (this._gridOptions && (this._gridOptions.enablePagination || !this._gridOptions.hasOwnProperty('enablePagination') || this.options?.infiniteScroll)) {
this.updateOptions({ paginationOptions });
}
}
@@ -339,7 +381,7 @@ export class GraphqlService implements BackendService {
* }
*/
processOnPaginationChanged(_event: Event | undefined, args: PaginationChangedArgs | (PaginationCursorChangedArgs & PaginationChangedArgs)): string {
- const pageSize = +(args.pageSize || ((this.pagination) ? this.pagination.pageSize : DEFAULT_PAGE_SIZE));
+ const pageSize = +((this.options?.infiniteScroll as InfiniteScrollOption)?.fetchSize || args.pageSize || ((this.pagination) ? this.pagination.pageSize : DEFAULT_PAGE_SIZE));
// if first/last defined on args, then it is a cursor based pagination change
'first' in args || 'last' in args
@@ -370,6 +412,11 @@ export class GraphqlService implements BackendService {
// loop through all columns to inspect sorters & set the query
this.updateSorters(sortColumns);
+ // when using infinite scroll, we need to go back to 1st page
+ if (this.options?.infiniteScroll) {
+ this.updateOptions({ paginationOptions: { offset: 0 } });
+ }
+
// build the GraphQL query which we will use in the WebAPI callback
return this.buildQuery();
}
@@ -571,7 +618,7 @@ export class GraphqlService implements BackendService {
// use offset based pagination
paginationOptions = {
first: pageSize,
- offset: (newPage > 1) ? ((newPage - 1) * pageSize!) : 0 // recalculate offset but make sure the result is always over 0
+ offset: (newPage > 1) ? ((newPage - 1) * pageSize) : 0 // recalculate offset but make sure the result is always over 0
};
}
diff --git a/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts b/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts
index 75ca2b7e7..6b87dfff2 100644
--- a/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts
+++ b/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts
@@ -505,7 +505,10 @@ export class SlickVanillaGridBundle {
this._eventPubSubService.eventNamingStyle = this._gridOptions?.eventNamingStyle ?? EventNamingStyle.camelCase;
this._paginationOptions = this.gridOptions?.pagination;
- this.createBackendApiInternalPostProcessCallback(this._gridOptions);
+ // unless specified, we'll create an internal postProcess callback (currently only available for GraphQL)
+ if (this._gridOptions.backendServiceApi && !this._gridOptions.backendServiceApi?.disableInternalPostProcess) {
+ this.createBackendApiInternalPostProcessCallback(this._gridOptions);
+ }
if (!this.customDataView) {
const dataviewInlineFilters = this._gridOptions?.dataView?.inlineFilters ?? false;
@@ -734,13 +737,14 @@ export class SlickVanillaGridBundle {
/**
* 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
+ * Currently ONLY available with the GraphQL Backend Service.
+ * The behavior is to refresh the Dataset & Pagination without requiring the user to create his own PostProcess every time
*/
createBackendApiInternalPostProcessCallback(gridOptions?: GridOption): void {
const backendApi = gridOptions?.backendServiceApi;
if (backendApi?.service) {
const backendApiService = backendApi.service;
+ this.addBackendInfiniteScrollCallback(gridOptions);
// 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') {
@@ -935,22 +939,29 @@ export class SlickVanillaGridBundle {
// when user enables Infinite Scroll
if (backendApi.service.options?.infiniteScroll) {
- this.slickGrid!.getOptions().backendServiceApi!.onScrollEnd = () => {
- this.backendUtilityService.setInfiniteScrollBottomHit(true);
-
- // even if we're not showing pagination, we still use pagination service behind the scene
- // to keep track of the scroll position and fetch next set of data (aka next page)
- // we also need a flag to know if we reached the of the dataset or not (no more pages)
- this.paginationService.goToNextPage().then(hasNext => {
- if (!hasNext) {
- this.backendUtilityService.setInfiniteScrollBottomHit(false);
- }
- });
- };
+ this.addBackendInfiniteScrollCallback();
}
}
}
+ protected addBackendInfiniteScrollCallback(gridOptions?: GridOption): void {
+ gridOptions ??= this.slickGrid!.getOptions();
+ if (gridOptions.backendServiceApi && !gridOptions.backendServiceApi?.onScrollEnd) {
+ gridOptions.backendServiceApi!.onScrollEnd = () => {
+ this.backendUtilityService.setInfiniteScrollBottomHit(true);
+
+ // even if we're not showing pagination, we still use pagination service behind the scene
+ // to keep track of the scroll position and fetch next set of data (aka next page)
+ // we also need a flag to know if we reached the of the dataset or not (no more pages)
+ this.paginationService.goToNextPage().then(hasNext => {
+ if (!hasNext) {
+ this.backendUtilityService.setInfiniteScrollBottomHit(false);
+ }
+ });
+ };
+ }
+ }
+
bindResizeHook(grid: SlickGrid, options: GridOption): void {
if ((options.autoFitColumnsOnFirstLoad && options.autosizeColumnsByCellContentOnFirstLoad) || (options.enableAutoSizeColumns && options.enableAutoResizeColumnsByCellContent)) {
throw new Error(`[Slickgrid-Universal] You cannot enable both autosize/fit viewport & resize by content, you must choose which resize technique to use. You can enable these 2 options ("autoFitColumnsOnFirstLoad" and "enableAutoSizeColumns") OR these other 2 options ("autosizeColumnsByCellContentOnFirstLoad" and "enableAutoResizeColumnsByCellContent").`);
diff --git a/test/cypress/e2e/example27.cy.ts b/test/cypress/e2e/example27.cy.ts
new file mode 100644
index 000000000..9ce23d636
--- /dev/null
+++ b/test/cypress/e2e/example27.cy.ts
@@ -0,0 +1,161 @@
+import { removeWhitespaces } from '../plugins/utilities';
+
+describe('Example 27 - GraphQL with Infinite Scroll', () => {
+ it('should display Example title', () => {
+ cy.visit(`${Cypress.config('baseUrl')}/example27`);
+ cy.get('h3').should('contain', 'Example 27 - GraphQL Backend Service with Infinite Scroll');
+ });
+
+ it('should have default GraphQL query', () => {
+ cy.get('[data-test=alert-graphql-query]').should('exist');
+ cy.get('[data-test=alert-graphql-query]').should('contain', 'GraphQL Query');
+
+ // wait for the query to finish
+ cy.get('[data-test=status]').should('contain', 'finished');
+
+ cy.get('[data-test=graphql-query-result]')
+ .should(($span) => {
+ const text = removeWhitespaces($span.text()); // remove all white spaces
+ expect(text).to.eq(removeWhitespaces(`query { users (first:30,offset:0,locale:"en",userId:123) { totalCount, nodes { id,name,gender,company } } }`));
+ });
+ });
+
+ it('should scroll to bottom of the grid and expect next batch of 30 items appended to current dataset for a total of 60 items', () => {
+ cy.get('[data-test="itemCount"]')
+ .should('have.text', '30');
+
+ cy.get('.slick-viewport.slick-viewport-top.slick-viewport-left')
+ .scrollTo('bottom');
+
+ cy.get('[data-test="itemCount"]')
+ .should('have.text', '60');
+
+ cy.get('[data-test=graphql-query-result]')
+ .should(($span) => {
+ const text = removeWhitespaces($span.text()); // remove all white spaces
+ expect(text).to.eq(removeWhitespaces(`query { users (first:30,offset:30,locale:"en",userId:123) { totalCount, nodes { id,name,gender,company } } }`));
+ });
+ });
+
+ it('should scroll to bottom of the grid and expect next batch of 30 items appended to current dataset for a new total of 90 items', () => {
+ cy.get('[data-test="itemCount"]')
+ .should('have.text', '60');
+
+ cy.get('.slick-viewport.slick-viewport-top.slick-viewport-left')
+ .scrollTo('bottom');
+
+ cy.get('[data-test="itemCount"]')
+ .should('have.text', '90');
+
+ cy.get('[data-test=graphql-query-result]')
+ .should(($span) => {
+ const text = removeWhitespaces($span.text()); // remove all white spaces
+ expect(text).to.eq(removeWhitespaces(`query { users (first:30,offset:60,locale:"en",userId:123) { totalCount, nodes { id,name,gender,company } } }`));
+ });
+ });
+
+ it('should do one last scroll to reach the end of the data and have a full total of 100 items', () => {
+ cy.get('[data-test="itemCount"]')
+ .should('have.text', '90');
+
+ cy.get('[data-test="data-loaded-tag"]')
+ .should('not.have.class', 'fully-loaded');
+
+ cy.get('.slick-viewport.slick-viewport-top.slick-viewport-left')
+ .scrollTo('bottom');
+
+ cy.get('[data-test="itemCount"]')
+ .should('have.text', '100');
+
+ cy.get('[data-test=graphql-query-result]')
+ .should(($span) => {
+ const text = removeWhitespaces($span.text()); // remove all white spaces
+ expect(text).to.eq(removeWhitespaces(`query { users (first:30,offset:90,locale:"en",userId:123) { totalCount, nodes { id,name,gender,company } } }`));
+ });
+
+ cy.get('[data-test="data-loaded-tag"]')
+ .should('have.class', 'fully-loaded');
+ });
+
+ it('should sort by Name column and expect dataset to restart at index zero and have a total of 30 items', () => {
+ cy.get('[data-test="data-loaded-tag"]')
+ .should('have.class', 'fully-loaded');
+
+
+ cy.get('[data-id="name"]')
+ .click();
+
+ cy.get('[data-test="itemCount"]')
+ .should('have.text', '30');
+
+ cy.get('[data-test=graphql-query-result]')
+ .should(($span) => {
+ const text = removeWhitespaces($span.text()); // remove all white spaces
+ expect(text).to.eq(removeWhitespaces(`query { users (first:30,offset:0,orderBy:[{field:name,direction:ASC}],locale:"en",userId:123) {
+ totalCount, nodes { id,name,gender,company } } }`));
+ });
+
+ cy.get('[data-test="data-loaded-tag"]')
+ .should('not.have.class', 'fully-loaded');
+ });
+
+ it('should scroll to bottom again and expect next batch of 30 items appended to current dataset for a total of 60 items', () => {
+ cy.get('[data-test="itemCount"]')
+ .should('have.text', '30');
+
+ cy.get('.slick-viewport.slick-viewport-top.slick-viewport-left')
+ .scrollTo('bottom');
+
+ cy.get('[data-test="itemCount"]')
+ .should('have.text', '60');
+
+ cy.get('[data-test=graphql-query-result]')
+ .should(($span) => {
+ const text = removeWhitespaces($span.text()); // remove all white spaces
+ expect(text).to.eq(removeWhitespaces(`query { users (first:30,offset:30,orderBy:[{field:name,direction:ASC}],locale:"en",userId:123) {
+ totalCount, nodes { id,name,gender,company } } }`));
+ });
+
+ cy.get('[data-test="data-loaded-tag"]')
+ .should('not.have.class', 'fully-loaded');
+ });
+
+ it('should change Gender filter to "female" and expect dataset to restart at index zero and have a total of 30 items', () => {
+ cy.get('.ms-filter.filter-gender:visible').click();
+
+ cy.get('[data-name="filter-gender"].ms-drop')
+ .find('li:visible:nth(2)')
+ .contains('Female')
+ .click();
+
+ cy.get('[data-test=graphql-query-result]')
+ .should(($span) => {
+ const text = removeWhitespaces($span.text()); // remove all white spaces
+ expect(text).to.eq(removeWhitespaces(`query { users (first:30,offset:0,
+ orderBy:[{field:name,direction:ASC}],
+ filterBy:[{field:gender,operator:EQ,value:"female"}],locale:"en",userId:123) { totalCount, nodes { id,name,gender,company } } }`));
+ });
+
+ cy.get('[data-test="data-loaded-tag"]')
+ .should('not.have.class', 'fully-loaded');
+ });
+
+ it('should scroll to bottom again and expect next batch to be only 20 females appended to current dataset for a total of 50 items found in DB', () => {
+ cy.get('[data-test="itemCount"]')
+ .should('have.text', '30');
+
+ cy.get('.slick-viewport.slick-viewport-top.slick-viewport-left')
+ .scrollTo('bottom');
+
+ cy.get('[data-test="itemCount"]')
+ .should('have.text', '50');
+
+ cy.get('[data-test=graphql-query-result]')
+ .should(($span) => {
+ const text = removeWhitespaces($span.text()); // remove all white spaces
+ expect(text).to.eq(removeWhitespaces(`query { users (first:30,offset:30,
+ orderBy:[{field:name,direction:ASC}],
+ filterBy:[{field:gender,operator:EQ,value:"female"}],locale:"en",userId:123) { totalCount, nodes { id,name,gender,company } } }`));
+ });
+ });
+});