From 1add6a3b0e400cf1f67f4d4bfa35f9d8e52e869e Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 17 Jul 2024 23:45:52 -0400 Subject: [PATCH] feat: Infinite Scroll for Backend Services (POC) --- .../src/app-routing.ts | 2 + .../vite-demo-vanilla-bundle/src/app.html | 3 + .../src/examples/example26.html | 70 +++ .../src/examples/example26.ts | 398 ++++++++++++++++++ packages/common/src/core/slickGrid.ts | 22 +- .../interfaces/backendService.interface.ts | 3 + .../interfaces/backendServiceApi.interface.ts | 3 + .../backendServiceOption.interface.ts | 9 +- .../src/interfaces/gridEvents.interface.ts | 2 +- .../src/interfaces/pagination.interface.ts | 2 +- .../common/src/services/pagination.service.ts | 14 +- .../odata/src/services/grid-odata.service.ts | 38 +- .../components/slick-vanilla-grid-bundle.ts | 32 +- 13 files changed, 573 insertions(+), 25 deletions(-) create mode 100644 examples/vite-demo-vanilla-bundle/src/examples/example26.html create mode 100644 examples/vite-demo-vanilla-bundle/src/examples/example26.ts diff --git a/examples/vite-demo-vanilla-bundle/src/app-routing.ts b/examples/vite-demo-vanilla-bundle/src/app-routing.ts index 04a10e803..aba400e9b 100644 --- a/examples/vite-demo-vanilla-bundle/src/app-routing.ts +++ b/examples/vite-demo-vanilla-bundle/src/app-routing.ts @@ -26,6 +26,7 @@ import Example22 from './examples/example22'; import Example23 from './examples/example23'; import Example24 from './examples/example24'; import Example25 from './examples/example25'; +import Example26 from './examples/example26'; export class AppRouting { constructor(private config: RouterConfig) { @@ -57,6 +58,7 @@ export class AppRouting { { route: 'example23', name: 'example23', view: './examples/example23.html', viewModel: Example23, title: 'Example23', }, { route: 'example24', name: 'example24', view: './examples/example24.html', viewModel: Example24, title: 'Example24', }, { route: 'example25', name: 'example25', view: './examples/example25.html', viewModel: Example25, title: 'Example25', }, + { route: 'example26', name: 'example26', view: './examples/example26.html', viewModel: Example26, title: 'Example26', }, { route: '', redirect: 'example01' }, { route: '**', redirect: 'example01' } ]; diff --git a/examples/vite-demo-vanilla-bundle/src/app.html b/examples/vite-demo-vanilla-bundle/src/app.html index e77eaa25f..e8f257cac 100644 --- a/examples/vite-demo-vanilla-bundle/src/app.html +++ b/examples/vite-demo-vanilla-bundle/src/app.html @@ -111,6 +111,9 @@

Slickgrid-Universal

Example25 - Range Filters + + Example26 - OData with Infinite Scroll + diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example26.html b/examples/vite-demo-vanilla-bundle/src/examples/example26.html new file mode 100644 index 000000000..b3d692a0c --- /dev/null +++ b/examples/vite-demo-vanilla-bundle/src/examples/example26.html @@ -0,0 +1,70 @@ +

+ Example 26 - OData Backend Service with Infinite Scroll + +

+ +
+ +
+ +
+ + + +
+ +
+
+
+ Backend Error: +
+
+
+ +
+
+
+ OData Query: + +
+
+
+
+ Status: +
+
+
+ +
+
\ No newline at end of file diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example26.ts b/examples/vite-demo-vanilla-bundle/src/examples/example26.ts new file mode 100644 index 000000000..561dafaa3 --- /dev/null +++ b/examples/vite-demo-vanilla-bundle/src/examples/example26.ts @@ -0,0 +1,398 @@ +import { BindingEventService } from '@slickgrid-universal/binding'; +import { type Column, FieldType, Filters, type GridOption, type Metrics, OperatorType, } from '@slickgrid-universal/common'; +import { GridOdataService, type OdataServiceApi } from '@slickgrid-universal/odata'; +import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle'; +import { ExampleGridOptions } from './example-grid-options'; +import Data from './data/customers_100.json'; +import './example09.scss'; + +const CARET_HTML_ESCAPED = '%5E'; +const PERCENT_HTML_ESCAPED = '%25'; + +export default class Example09 { + private _bindingEventService: BindingEventService; + private _scrollNextPage = false; + backendService: GridOdataService; + columnDefinitions: Column[]; + gridOptions: GridOption; + metrics: Metrics; + sgb: SlickVanillaGridBundle; + + odataQuery = ''; + processing = false; + errorStatus = ''; + errorStatusClass = 'hidden'; + status = ''; + statusClass = 'is-success'; + isPageErrorTest = false; + + constructor() { + this._bindingEventService = new BindingEventService(); + this.resetAllStatus(); + this.backendService = new GridOdataService(); + } + + attached() { + this.initializeGrid(); + const gridContainerElm = document.querySelector(`.grid9`) as HTMLDivElement; + + this.sgb = new Slicker.GridBundle(gridContainerElm, this.columnDefinitions, { ...ExampleGridOptions, ...this.gridOptions }, []); + } + + dispose() { + if (this.sgb) { + this.sgb?.dispose(); + } + this._bindingEventService.unbindAll(); + this.resetAllStatus(); + } + + resetAllStatus() { + this.status = ''; + this.errorStatus = ''; + this.statusClass = 'is-success'; + this.errorStatusClass = 'hidden'; + } + + initializeGrid() { + this.columnDefinitions = [ + { + id: 'name', name: 'Name', field: 'name', sortable: true, + type: FieldType.string, + filterable: true, + filter: { + model: Filters.compoundInput, + compoundOperatorList: [ + { operator: '', desc: 'Contains' }, + { operator: '<>', desc: 'Not Contains' }, + { operator: '=', desc: 'Equals' }, + { operator: '!=', desc: 'Not equal to' }, + { operator: 'a*', desc: 'Starts With' }, + { operator: 'Custom', desc: 'SQL Like' }, + ], + } + }, + { + id: 'gender', name: 'Gender', field: 'gender', filterable: true, sortable: true, + filter: { + model: Filters.singleSelect, + collection: [{ value: '', label: '' }, { value: 'male', label: 'male' }, { value: 'female', label: 'female' }] + } + }, + { id: 'company', name: 'Company', field: 'company', filterable: true, sortable: true }, + { id: 'category_name', name: 'Category', field: 'category/name', filterable: true, sortable: true } + ]; + + this.gridOptions = { + enableAutoResize: true, + autoResize: { + container: '.demo-container', + rightPadding: 10 + }, + checkboxSelector: { + // you can toggle these 2 properties to show the "select all" checkbox in different location + hideInFilterHeaderRow: false, + hideInColumnTitleRow: true + }, + compoundOperatorAltTexts: { + // where '=' is any of the `OperatorString` type shown above + text: { 'Custom': { operatorAlt: '%%', descAlt: 'SQL Like' } }, + }, + enableCellNavigation: true, + enableFiltering: true, + enableCheckboxSelector: true, + enableRowSelection: true, + presets: { + // NOTE: pagination preset is NOT supported with infinite scroll + // filters: [{ columnId: 'gender', searchTerms: ['female'] }] + }, + backendServiceApi: { + service: this.backendService, + options: { + // infiniteScroll: true, // as Boolean OR { fetchSize: number } + infiniteScroll: { fetchSize: 30 }, + + enableCount: true, + filterQueryOverride: ({ fieldName, columnDef, columnFilterOperator, searchValue }) => { + if (columnFilterOperator === OperatorType.custom && columnDef?.id === 'name') { + let matchesSearch = (searchValue as string).replace(/\*/g, '.*'); + matchesSearch = matchesSearch.slice(0, 1) + CARET_HTML_ESCAPED + matchesSearch.slice(1); + matchesSearch = matchesSearch.slice(0, -1) + '$\''; + + return `matchesPattern(${fieldName}, ${matchesSearch})`; + } + }, + version: 4 + }, + onError: (error: Error) => { + this.errorStatus = error.message; + this.errorStatusClass = 'visible notification is-light is-danger is-small is-narrow'; + this.displaySpinner(false, true); + }, + onScrollEnd: async () => { + this._scrollNextPage = true; + this.sgb.paginationService.goToNextPage().then(hasNext => { + if (!hasNext) { + this._scrollNextPage = false; + } + }); + }, + preProcess: () => { + this.errorStatus = ''; + this.errorStatusClass = 'hidden'; + this.displaySpinner(true); + }, + process: (query) => this.getCustomerApiCall(query), + postProcess: (response) => { + this.metrics = response.metrics; + this.displaySpinner(false); + this.getCustomerCallback(response); + }, + } as OdataServiceApi + }; + } + + displaySpinner(isProcessing, isError?: boolean) { + this.processing = isProcessing; + + if (isError) { + this.status = 'ERROR!!!'; + this.statusClass = 'notification is-light is-danger'; + } else { + this.status = (isProcessing) ? 'loading...' : 'finished!!'; + this.statusClass = (isProcessing) ? 'notification is-light is-warning' : 'notification is-light is-success'; + } + } + + getCustomerCallback(data) { + console.log('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 + const totalItemCount: number = data['@odata.count']; + if (this.metrics) { + this.metrics.totalItemCount = totalItemCount; + } + + // once pagination totalItems is filled, we can update the dataset + this.sgb.paginationOptions!.totalItems = totalItemCount; + this.sgb.dataset = this._scrollNextPage + ? [...this.sgb.dataset, ...data.value] + : data.value; + + this.odataQuery = data['query']; + this._scrollNextPage = false; + } + + getCustomerApiCall(query) { + // 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); + } + + /** + * 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 { + this.errorStatusClass = 'hidden'; + + // the mock is returning a Promise, just like a WebAPI typically does + return new Promise((resolve) => { + const queryParams = query.toLowerCase().split('&'); + let top = 0; + let skip = 0; + let orderBy = ''; + let countTotalItems = 100; + const columnFilters = {}; + + if (this.isPageErrorTest) { + this.isPageErrorTest = false; + throw new Error('Server timed out trying to retrieve data for the last page'); + } + + for (const param of queryParams) { + if (param.includes('$top=')) { + top = +(param.substring('$top='.length)); + if (top === 50000) { + throw new Error('Server timed out retrieving 50,000 rows'); + } + } + if (param.includes('$skip=')) { + skip = +(param.substring('$skip='.length)); + } + if (param.includes('$orderby=')) { + orderBy = param.substring('$orderby='.length); + } + if (param.includes('$filter=')) { + 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 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 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 fieldName = filterMatch[2].trim(); + columnFilters[fieldName] = { type: 'substring', term: filterMatch[1].trim() }; + } + for (const operator of ['eq', 'ne', 'le', 'lt', 'gt', 'ge']) { + if (filterBy.includes(operator)) { + const re = new RegExp(`([a-zA-Z ]*) ${operator} '(.*?)'`); + const filterMatch = re.exec(filterBy); + if (Array.isArray(filterMatch)) { + const fieldName = filterMatch[1].trim(); + columnFilters[fieldName] = { type: operator, term: filterMatch[2].trim() }; + } + } + } + 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 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 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 fieldName = filterMatch[1].trim(); + columnFilters[fieldName] = { type: 'ends', term: filterMatch[2].trim() }; + } + + // simular a backend error when trying to sort on the "Company" field + if (filterBy.includes('company')) { + throw new Error('Server could not filter using the field "Company"'); + } + } + } + + // simulate a backend error when trying to sort on the "Company" field + if (orderBy.includes('company')) { + throw new Error('Server could not sort using the field "Company"'); + } + + // read the JSON and create a fresh copy of the data that we are free to modify + let data = Data as unknown as { name: string; gender: string; company: string; id: string, category: { id: string; name: string; }; }[]; + data = JSON.parse(JSON.stringify(data)); + + // Sort the data + if (orderBy?.length > 0) { + const orderByClauses = orderBy.split(','); + for (const orderByClause of orderByClauses) { + const orderByParts = orderByClause.split(' '); + const orderByField = orderByParts[0]; + + let selector = (obj: any): string => obj; + for (const orderByFieldPart of orderByField.split('/')) { + const prevSelector = selector; + selector = (obj: any) => { + return prevSelector(obj)[orderByFieldPart]; + }; + } + + const sort = orderByParts[1] ?? 'asc'; + switch (sort.toLocaleLowerCase()) { + case 'asc': + data = data.sort((a, b) => selector(a).localeCompare(selector(b))); + break; + case 'desc': + data = data.sort((a, b) => selector(b).localeCompare(selector(a))); + break; + } + } + } + + // Read the result field from the JSON response. + let firstRow = skip; + let filteredData = data; + if (columnFilters) { + for (const columnId in columnFilters) { + if (columnFilters.hasOwnProperty(columnId)) { + filteredData = filteredData.filter(column => { + const filterType = columnFilters[columnId].type; + const searchTerm = columnFilters[columnId].term; + let colId = columnId; + if (columnId?.indexOf(' ') !== -1) { + const splitIds = columnId.split(' '); + colId = splitIds[splitIds.length - 1]; + } + + let filterTerm; + let col = column; + for (const part of colId.split('/')) { + filterTerm = col[part]; + col = filterTerm; + } + + if (filterTerm) { + const [term1, term2] = Array.isArray(searchTerm) ? searchTerm : [searchTerm]; + + switch (filterType) { + case 'eq': return filterTerm.toLowerCase() === term1; + case 'ne': return filterTerm.toLowerCase() !== term1; + case 'le': return filterTerm.toLowerCase() <= term1; + case 'lt': return filterTerm.toLowerCase() < term1; + case 'gt': return filterTerm.toLowerCase() > term1; + case 'ge': return filterTerm.toLowerCase() >= term1; + case 'ends': return filterTerm.toLowerCase().endsWith(term1); + case 'starts': return filterTerm.toLowerCase().startsWith(term1); + case 'starts+ends': return filterTerm.toLowerCase().startsWith(term1) && filterTerm.toLowerCase().endsWith(term2); + case 'substring': return filterTerm.toLowerCase().includes(term1); + case 'matchespattern': return new RegExp((term1 as string).replaceAll(PERCENT_HTML_ESCAPED, '.*'), 'i').test(filterTerm); + } + } + }); + } + } + countTotalItems = filteredData.length; + } + + // make sure page skip is not out of boundaries, if so reset to first page & remove skip from query + if (firstRow > filteredData.length) { + query = query.replace(`$skip=${firstRow}`, ''); + firstRow = 0; + } + const updatedData = filteredData.slice(firstRow, firstRow + top); + + setTimeout(() => { + const backendResult = { query }; + backendResult['value'] = updatedData; + backendResult['@odata.count'] = countTotalItems; + + // console.log('Backend Result', backendResult); + resolve(backendResult); + }, 150); + }); + } + + clearAllFiltersAndSorts() { + if (this.sgb?.gridService) { + this.sgb.gridService.clearAllFiltersAndSorts(); + } + } + + setFiltersDynamically() { + // we can Set Filters Dynamically (or different filters) afterward through the FilterService + this.sgb?.filterService.updateFilters([ + { columnId: 'gender', searchTerms: ['female'] }, + ]); + } + + setSortingDynamically() { + this.sgb?.sortService.updateSorting([ + { columnId: 'name', direction: 'DESC' }, + ]); + } + + throwPageChangeError() { + this.isPageErrorTest = true; + this.sgb.paginationService.goToLastPage(); + } +} diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts index 7b3d5faba..c2ba878c7 100644 --- a/packages/common/src/core/slickGrid.ts +++ b/packages/common/src/core/slickGrid.ts @@ -389,6 +389,7 @@ export class SlickGrid = Column, O e protected renderedRows = 0; protected numVisibleRows = 0; protected prevScrollTop = 0; + protected scrollHeight = 0; protected scrollTop = 0; protected lastRenderedScrollTop = 0; protected lastRenderedScrollLeft = 0; @@ -3966,6 +3967,7 @@ export class SlickGrid = Column, O e } this.scrollTop = this._viewportScrollContainerY.scrollTop; + this.scrollHeight = this._viewportScrollContainerY.scrollHeight; this.enforceFrozenRowHeightRecalc = false; // reset enforce flag } @@ -4413,13 +4415,14 @@ export class SlickGrid = Column, O e } } - protected handleScroll(): boolean { + protected handleScroll(e?: Event): boolean { + this.scrollHeight = this._viewportScrollContainerY.scrollHeight; this.scrollTop = this._viewportScrollContainerY.scrollTop; this.scrollLeft = this._viewportScrollContainerX.scrollLeft; - return this._handleScroll(false); + return this._handleScroll(e ? 'scroll' : 'system'); } - protected _handleScroll(isMouseWheel: boolean): boolean { + protected _handleScroll(eventType: 'mousewheel' | 'scroll' | 'system' = 'system'): boolean { let maxScrollDistanceY = this._viewportScrollContainerY.scrollHeight - this._viewportScrollContainerY.clientHeight; let maxScrollDistanceX = this._viewportScrollContainerY.scrollWidth - this._viewportScrollContainerY.clientWidth; @@ -4431,6 +4434,7 @@ export class SlickGrid = Column, O e // Ceiling the max scroll values if (this.scrollTop > maxScrollDistanceY) { this.scrollTop = maxScrollDistanceY; + this.scrollHeight = maxScrollDistanceY; } if (this.scrollLeft > maxScrollDistanceX) { this.scrollLeft = maxScrollDistanceX; @@ -4480,7 +4484,7 @@ export class SlickGrid = Column, O e this.vScrollDir = this.prevScrollTop < this.scrollTop ? 1 : -1; this.prevScrollTop = this.scrollTop; - if (isMouseWheel) { + if (eventType === 'mousewheel') { this._viewportScrollContainerY.scrollTop = this.scrollTop; } @@ -4525,7 +4529,12 @@ export class SlickGrid = Column, O e } } - this.triggerEvent(this.onScroll, { scrollLeft: this.scrollLeft, scrollTop: this.scrollTop }); + this.triggerEvent(this.onScroll, { + triggeredBy: eventType, + scrollHeight: this.scrollHeight, + scrollLeft: this.scrollLeft, + scrollTop: this.scrollTop, + }); if (hScrollDist || vScrollDist) { return true; @@ -4782,9 +4791,10 @@ export class SlickGrid = Column, O e // Interactivity protected handleMouseWheel(e: MouseEvent, _delta: number, deltaX: number, deltaY: number): void { + this.scrollHeight = this._viewportScrollContainerY.scrollHeight; this.scrollTop = Math.max(0, this._viewportScrollContainerY.scrollTop - (deltaY * this._options.rowHeight!)); this.scrollLeft = this._viewportScrollContainerX.scrollLeft + (deltaX * 10); - const handled = this._handleScroll(true); + const handled = this._handleScroll('mousewheel'); if (handled) { e.preventDefault(); } diff --git a/packages/common/src/interfaces/backendService.interface.ts b/packages/common/src/interfaces/backendService.interface.ts index a53fd8645..d78b35ee8 100644 --- a/packages/common/src/interfaces/backendService.interface.ts +++ b/packages/common/src/interfaces/backendService.interface.ts @@ -18,6 +18,9 @@ export interface BackendService { /** Backend Service options */ options?: BackendServiceOption; + /** Optional dispose method */ + dispose?: () => void; + /** Build and the return the backend service query string */ buildQuery: (serviceOptions?: BackendServiceOption) => string; diff --git a/packages/common/src/interfaces/backendServiceApi.interface.ts b/packages/common/src/interfaces/backendServiceApi.interface.ts index 2923d350b..97fbec623 100644 --- a/packages/common/src/interfaces/backendServiceApi.interface.ts +++ b/packages/common/src/interfaces/backendServiceApi.interface.ts @@ -37,6 +37,9 @@ export interface BackendServiceApi { /** On init (or on page load), what action to perform? */ onInit?: (query: string) => Promise | Observable; + /** When user reaches the end of the scroll (only works with infinite scroll enabled) */ + onScrollEnd?: () => void; + /** Before executing the query, what action to perform? For example, start a spinner */ preProcess?: () => void; diff --git a/packages/common/src/interfaces/backendServiceOption.interface.ts b/packages/common/src/interfaces/backendServiceOption.interface.ts index 52458bb15..fe09f26cf 100644 --- a/packages/common/src/interfaces/backendServiceOption.interface.ts +++ b/packages/common/src/interfaces/backendServiceOption.interface.ts @@ -2,7 +2,14 @@ import type { SlickGrid } from '../core'; import type { OperatorType } from '../enums'; import type { Column } from './column.interface'; +export interface InfiniteScrollOption { + fetchSize: number; +} + export interface BackendServiceOption { + /** Infinite Scroll will fetch next page but without showing any pagination in the UI. */ + infiniteScroll?: boolean | InfiniteScrollOption; + /** What are the pagination options? ex.: (first, last, offset) */ paginationOptions?: any; @@ -28,5 +35,5 @@ export interface BackendServiceFilterQueryOverrideArgs { /** The entered search value */ searchValue: any; /** A reference to the SlickGrid instance */ - grid: SlickGrid | undefined + grid: SlickGrid | undefined; } \ No newline at end of file diff --git a/packages/common/src/interfaces/gridEvents.interface.ts b/packages/common/src/interfaces/gridEvents.interface.ts index 6974a4425..8e5a99a6b 100644 --- a/packages/common/src/interfaces/gridEvents.interface.ts +++ b/packages/common/src/interfaces/gridEvents.interface.ts @@ -40,7 +40,7 @@ export interface OnRenderedEventArgs extends SlickGridArg { startRow: number; en export interface OnSelectedRowsChangedEventArgs extends SlickGridArg { rows: number[]; previousSelectedRows: number[]; changedSelectedRows: number[]; changedUnselectedRows: number[]; caller: string; } export interface OnSetOptionsEventArgs extends SlickGridArg { optionsBefore: GridOption; optionsAfter: GridOption; } export interface OnActivateChangedOptionsEventArgs extends SlickGridArg { options: GridOption; } -export interface OnScrollEventArgs extends SlickGridArg { scrollLeft: number; scrollTop: number; } +export interface OnScrollEventArgs extends SlickGridArg { scrollLeft: number; scrollTop: number; scrollHeight: number; triggeredBy?: string; } export interface OnDragEventArgs extends SlickGridArg { count: number; deltaX: number; deltaY: number; offsetX: number; offsetY: number; originalX: number; originalY: number; available: HTMLDivElement | HTMLDivElement[]; drag: HTMLDivElement; drop: HTMLDivElement | HTMLDivElement[]; helper: HTMLDivElement; diff --git a/packages/common/src/interfaces/pagination.interface.ts b/packages/common/src/interfaces/pagination.interface.ts index af5b706c3..7014136ba 100644 --- a/packages/common/src/interfaces/pagination.interface.ts +++ b/packages/common/src/interfaces/pagination.interface.ts @@ -3,7 +3,7 @@ export interface Pagination { pageNumber?: number; /** The available page sizes */ - pageSizes: number[]; + pageSizes?: number[]; /** Current page size chosen */ pageSize: number; diff --git a/packages/common/src/services/pagination.service.ts b/packages/common/src/services/pagination.service.ts index fbae068fd..9d6c7516c 100644 --- a/packages/common/src/services/pagination.service.ts +++ b/packages/common/src/services/pagination.service.ts @@ -114,7 +114,7 @@ export class PaginationService { } init(grid: SlickGrid, paginationOptions: Pagination, backendServiceApi?: BackendServiceApi): void { - this._availablePageSizes = paginationOptions.pageSizes; + this._availablePageSizes = paginationOptions.pageSizes || []; this.grid = grid; this._backendServiceApi = backendServiceApi; this._paginationOptions = paginationOptions; @@ -145,6 +145,11 @@ export class PaginationService { this._subscriptions.push(this.pubSubService.subscribe('onFilterChanged', () => this.resetPagination())); this._subscriptions.push(this.pubSubService.subscribe('onFilterCleared', () => this.resetPagination())); + // when using Infinite Scroll (only), we also need to reset pagination when sorting + if (backendServiceApi?.options?.infiniteScroll) { + this._subscriptions.push(this.pubSubService.subscribe('onSortChanged', () => this.resetPagination())); + } + // Subscribe to any dataview row count changed so that when Adding/Deleting item(s) through the DataView // that would trigger a refresh of the pagination numbers if (this.dataView) { @@ -310,7 +315,7 @@ export class PaginationService { } // calculate and refresh the multiple properties of the pagination UI - this._availablePageSizes = pagination.pageSizes; + this._availablePageSizes = pagination.pageSizes || []; if (!this._totalItems && pagination.totalItems) { this._totalItems = pagination.totalItems; } @@ -337,13 +342,16 @@ export class PaginationService { } /** Reset the Pagination to first page and recalculate necessary numbers */ - resetPagination(triggerChangedEvent = true): void { + resetPagination(triggerChangedEvent = true, scrollToTop = true): void { if (this._isLocalGrid && this.dataView && this.sharedService?.gridOptions?.enablePagination) { // on a local grid we also need to reset the DataView paging to 1st page this.dataView.setPagingOptions({ pageSize: this._itemsPerPage, pageNum: 0 }); } this._cursorPageInfo = undefined; this.refreshPagination(true, triggerChangedEvent); + if (scrollToTop) { + this.grid.scrollTo(0); + } } /** diff --git a/packages/odata/src/services/grid-odata.service.ts b/packages/odata/src/services/grid-odata.service.ts index 8af92796c..00183f016 100644 --- a/packages/odata/src/services/grid-odata.service.ts +++ b/packages/odata/src/services/grid-odata.service.ts @@ -10,6 +10,7 @@ import type { CurrentSorter, FilterChangedArgs, GridOption, + InfiniteScrollOption, MultiColumnSort, Pagination, PaginationChangedArgs, @@ -26,6 +27,7 @@ import { mapOperatorByFieldType, OperatorType, parseUtcDate, + SlickEventHandler, SortDirection, } from '@slickgrid-universal/common'; import { getHtmlStringOutput, stripTags, titleCase } from '@slickgrid-universal/utils'; @@ -40,8 +42,10 @@ export class GridOdataService implements BackendService { protected _currentPagination: CurrentPagination | null = null; protected _currentSorters: CurrentSorter[] = []; protected _columnDefinitions: Column[] = []; + protected _eventHandler: SlickEventHandler; protected _grid: SlickGrid | undefined; protected _odataService: OdataQueryBuilderService; + protected _scrollEndCalled = false; options?: Partial; pagination: Pagination | undefined; defaultOptions: OdataOption = { @@ -66,6 +70,7 @@ export class GridOdataService implements BackendService { } constructor() { + this._eventHandler = new SlickEventHandler(); this._odataService = new OdataQueryBuilderService(); } @@ -74,12 +79,12 @@ export class GridOdataService implements BackendService { 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) { + if (this._gridOptions && !this._gridOptions.enablePagination && this.options?.infiniteScroll) { // 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; + const topOption = (mergedOptions.infiniteScroll as InfiniteScrollOption)?.fetchSize ?? pagination?.pageSize ?? this.defaultOptions.top; this._odataService.options = { ...mergedOptions, top: topOption }; this._currentPagination = { pageNumber: 1, @@ -97,6 +102,25 @@ export class GridOdataService implements BackendService { this._odataService.columnDefinitions = this._columnDefinitions; this._odataService.datasetIdPropName = this._gridOptions.datasetIdPropertyName || 'id'; + + if (grid && mergedOptions.infiniteScroll) { + this._eventHandler.subscribe(grid.onScroll, (_e, args) => { + const viewportElm = args.grid.getViewportNode()!; + if (['mousewheel', 'scroll'].includes(args.triggeredBy || '') && args.scrollTop > 0 && this.pagination?.totalItems && Math.ceil(viewportElm.offsetHeight + args.scrollTop) >= args.scrollHeight) { + if (!this._scrollEndCalled) { + const backendApi = this._gridOptions.backendServiceApi; + backendApi?.onScrollEnd?.(); + this._scrollEndCalled = true; + } + } + }); + } + } + + /** Dispose the service */ + dispose(): void { + // unsubscribe all SlickGrid events + this._eventHandler.unsubscribeAll(); } buildQuery(): string { @@ -104,6 +128,7 @@ export class GridOdataService implements BackendService { } postProcess(processResult: any): void { + this._scrollEndCalled = false; const odataVersion = this._odataService.options.version ?? 2; if (this.pagination && this._odataService.options.enableCount) { @@ -268,7 +293,7 @@ export class GridOdataService implements BackendService { * PAGINATION */ processOnPaginationChanged(_event: Event | undefined, args: 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)); this.updatePagination(args.newPage, pageSize); // build the OData query which we will use in the WebAPI callback @@ -284,6 +309,11 @@ export class GridOdataService 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._odataService.updateOptions({ skip: undefined }); + } + // build the OData query which we will use in the WebAPI callback return this._odataService.buildQuery(); } @@ -512,7 +542,7 @@ export class GridOdataService 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._odataService.updateOptions({ top: pageSize, skip: (newPage - 1) * pageSize 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 557c966b7..4e1e22588 100644 --- a/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts +++ b/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts @@ -1,5 +1,6 @@ import { dequal } from 'dequal/lite'; import type { + BackendService, BackendServiceApi, BackendServiceOption, Column, @@ -9,10 +10,10 @@ import type { GridOption, Metrics, Pagination, + RxJsFacade, SelectEditor, ServicePagination, Subscription, - RxJsFacade, } from '@slickgrid-universal/common'; import { @@ -122,6 +123,10 @@ export class SlickVanillaGridBundle { slickFooter: SlickFooterComponent | undefined; slickPagination: SlickPaginationComponent | undefined; + get backendService(): BackendService | undefined { + return this.gridOptions.backendServiceApi?.service; + } + get eventHandler(): SlickEventHandler { return this._eventHandler; } @@ -422,6 +427,9 @@ export class SlickVanillaGridBundle { this.treeDataService?.dispose(); this.universalContainerService?.dispose(); + // dispose backend service when defined and a dispose method exists + this.backendService?.dispose?.(); + // dispose all registered external resources this.disposeExternalResources(); @@ -670,7 +678,7 @@ export class SlickVanillaGridBundle { dispose: this.dispose.bind(this), // return all available Services (non-singleton) - backendService: this.gridOptions?.backendServiceApi?.service, + backendService: this.backendService, eventPubSubService: this._eventPubSubService, filterService: this.filterService, gridEventService: this.gridEventService, @@ -693,6 +701,10 @@ export class SlickVanillaGridBundle { this._isGridInitialized = true; } + hasInfiniteScroll(): boolean { + return !!this.backendService?.options?.infiniteScroll; + } + mergeGridOptions(gridOptions: GridOption): GridOption { const options = extend(true, {}, GlobalGridOptions, gridOptions); @@ -877,7 +889,7 @@ export class SlickVanillaGridBundle { backendApiService.updateSorters(undefined, sortColumns); } // Pagination "presets" - if (backendApiService.updatePagination && gridOptions.presets.pagination) { + if (backendApiService.updatePagination && gridOptions.presets.pagination && !this.hasInfiniteScroll()) { const { pageNumber, pageSize } = gridOptions.presets.pagination; backendApiService.updatePagination(pageNumber, pageSize); } @@ -1010,7 +1022,7 @@ export class SlickVanillaGridBundle { } // 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; + this.showPagination = !!(this._gridOptions && (this._gridOptions.enablePagination || (this._gridOptions.backendServiceApi && this._gridOptions.enablePagination === undefined))); if (this._paginationOptions && this._gridOptions?.pagination && this._gridOptions?.backendServiceApi) { const paginationOptions = this.setPaginationOptionsWhenPresetDefined(this._gridOptions, this._paginationOptions); @@ -1094,8 +1106,12 @@ export class SlickVanillaGridBundle { */ setPaginationOptionsWhenPresetDefined(gridOptions: GridOption, paginationOptions: Pagination): Pagination { if (gridOptions.presets?.pagination && paginationOptions && !this._isPaginationInitialized) { - paginationOptions.pageSize = gridOptions.presets.pagination.pageSize; - paginationOptions.pageNumber = gridOptions.presets.pagination.pageNumber; + if (this.hasInfiniteScroll()) { + console.warn('[Slickgrid-Universal] `presets.pagination` is not supported with Infinite Scroll, reverting to first page.'); + } else { + paginationOptions.pageSize = gridOptions.presets.pagination.pageSize; + paginationOptions.pageNumber = gridOptions.presets.pagination.pageNumber; + } } return paginationOptions; } @@ -1184,9 +1200,7 @@ export class SlickVanillaGridBundle { this.slickPagination.renderPagination(this._gridParentContainerElm); this._isPaginationInitialized = true; } else if (!showPagination) { - if (this.slickPagination) { - this.slickPagination.dispose(); - } + this.slickPagination?.dispose(); this._isPaginationInitialized = false; } }