diff --git a/src/app/modules/angular-slickgrid/components/angular-slickgrid.component.spec.ts b/src/app/modules/angular-slickgrid/components/angular-slickgrid.component.spec.ts index 3a9076225..362a6b7a7 100644 --- a/src/app/modules/angular-slickgrid/components/angular-slickgrid.component.spec.ts +++ b/src/app/modules/angular-slickgrid/components/angular-slickgrid.component.spec.ts @@ -1,14 +1,15 @@ -import { AngularUtilService } from '../services/angularUtil.service'; -import { SlickgridConfig } from './../slickgrid-config'; -import { FilterFactory } from './../filters/filterFactory'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { SlickgridConfig } from './../slickgrid-config'; +import { FilterFactory } from './../filters/filterFactory'; + import { AngularSlickgridComponent } from './angular-slickgrid.component'; import { SlickPaginationComponent } from './slick-pagination.component'; import { CollectionService } from '../services/collection.service'; import { + AngularUtilService, ExportService, ExtensionService, FilterService, @@ -16,6 +17,7 @@ import { GridEventService, GridStateService, GroupingAndColspanService, + PaginationService, ResizerService, SharedService, SortService @@ -81,6 +83,7 @@ describe('App Component', () => { GridEventService, GridStateService, GroupingAndColspanService, + PaginationService, ResizerService, SharedService, TranslateService, diff --git a/src/app/modules/angular-slickgrid/components/angular-slickgrid.component.ts b/src/app/modules/angular-slickgrid/components/angular-slickgrid.component.ts index 2f204aa81..2020591d7 100644 --- a/src/app/modules/angular-slickgrid/components/angular-slickgrid.component.ts +++ b/src/app/modules/angular-slickgrid/components/angular-slickgrid.component.ts @@ -38,6 +38,7 @@ import { GridEventService } from './../services/gridEvent.service'; import { GridService } from './../services/grid.service'; import { GridStateService } from './../services/gridState.service'; import { GroupingAndColspanService } from './../services/groupingAndColspan.service'; +import { PaginationService } from '../services/pagination.service'; import { ResizerService } from './../services/resizer.service'; import { SharedService } from '../services/shared.service'; import { SortService } from './../services/sort.service'; @@ -88,6 +89,7 @@ const slickgridEventPrefix = 'sg'; GroupItemMetaProviderExtension, HeaderButtonExtension, HeaderMenuExtension, + PaginationService, ResizerService, RowDetailViewExtension, RowMoveManagerExtension, @@ -165,6 +167,7 @@ export class AngularSlickgridComponent implements AfterViewInit, OnDestroy, OnIn private gridEventService: GridEventService, private gridStateService: GridStateService, private groupingAndColspanService: GroupingAndColspanService, + private paginationService: PaginationService, private resizer: ResizerService, private sharedService: SharedService, private sortService: SortService, @@ -196,6 +199,7 @@ export class AngularSlickgridComponent implements AfterViewInit, OnDestroy, OnIn this.gridEventService.dispose(); this.gridStateService.dispose(); this.groupingAndColspanService.dispose(); + this.paginationService.dispose(); this.resizer.dispose(); this.sortService.dispose(); if (this._eventHandler && this._eventHandler.unsubscribeAll) { @@ -345,6 +349,7 @@ export class AngularSlickgridComponent implements AfterViewInit, OnDestroy, OnIn gridStateService: this.gridStateService, gridService: this.gridService, groupingService: this.groupingAndColspanService, + paginationService: this.paginationService, resizerService: this.resizer, sortService: this.sortService, diff --git a/src/app/modules/angular-slickgrid/components/slick-pagination.component.html b/src/app/modules/angular-slickgrid/components/slick-pagination.component.html index 7d6d74a09..c3e259b69 100644 --- a/src/app/modules/angular-slickgrid/components/slick-pagination.component.html +++ b/src/app/modules/angular-slickgrid/components/slick-pagination.component.html @@ -1,44 +1,51 @@ -
-
- +
+
+ -
- {{textPage}} - - {{textOf}} {{pageCount}} -
- - +
+ {{textPage}} + + {{textOf}} {{pager?.pageCount}}
- - - {{textItemsPerPage}}, - - {{dataFrom}}-{{dataTo}} {{textOf}} {{totalItems}} {{textItems}} - + + +
+ + + {{textItemsPerPage}}, + + {{pager?.from}}-{{pager?.to}} {{textOf}} {{pager?.totalItems}} {{textItems}} -
+ +
diff --git a/src/app/modules/angular-slickgrid/components/slick-pagination.component.ts b/src/app/modules/angular-slickgrid/components/slick-pagination.component.ts index 22f9b9183..7bcad25e7 100644 --- a/src/app/modules/angular-slickgrid/components/slick-pagination.component.ts +++ b/src/app/modules/angular-slickgrid/components/slick-pagination.component.ts @@ -1,26 +1,22 @@ import { AfterViewInit, Component, EventEmitter, Injectable, Input, OnDestroy, Optional, Output } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; +import { Subscription } from 'rxjs'; + import { Constants } from '../constants'; -import { GraphqlResult, GridOption, Locale, Pagination } from './../models/index'; -import { executeBackendProcessesCallback, onBackendError } from '../services/backend-utilities'; -import { FilterService } from './../services/filter.service'; -import { GridService } from './../services/grid.service'; -import { isObservable, Subscription } from 'rxjs'; +import { GridOption, Locale, Pager, Pagination } from './../models/index'; +import { PaginationService } from '../services/pagination.service'; import { unsubscribeAllObservables } from '../services/utilities'; -// using external non-typed js libraries -declare var Slick: any; - @Component({ selector: 'slick-pagination', templateUrl: './slick-pagination.component.html' }) @Injectable() export class SlickPaginationComponent implements AfterViewInit, OnDestroy { - private _eventHandler = new Slick.EventHandler(); private _gridPaginationOptions: GridOption; private _isFirstRender = true; private _locales: Locale; + private _pager: Pager; private subscriptions: Subscription[] = []; @Output() onPaginationChanged = new EventEmitter(); @@ -28,7 +24,7 @@ export class SlickPaginationComponent implements AfterViewInit, OnDestroy { @Input() set gridPaginationOptions(gridPaginationOptions: GridOption) { this._gridPaginationOptions = gridPaginationOptions; - if (this._isFirstRender || !gridPaginationOptions || !gridPaginationOptions.pagination || (gridPaginationOptions.pagination.totalItems !== this.totalItems)) { + if (this._isFirstRender || !gridPaginationOptions || !gridPaginationOptions.pagination || (gridPaginationOptions.pagination.totalItems !== this.pager.totalItems)) { this.refreshPagination(); this._isFirstRender = false; } @@ -37,15 +33,6 @@ export class SlickPaginationComponent implements AfterViewInit, OnDestroy { return this._gridPaginationOptions; } @Input() grid: any; - dataFrom = 1; - dataTo = 1; - itemsPerPage: number; - pageCount = 0; - pageNumber = 1; - totalItems = 0; - paginationCallback: Function; - paginationPageSizes = [25, 75, 100]; - fromToParams: any = { from: this.dataFrom, to: this.dataTo, totalItems: this.totalItems }; // text translations (handled by ngx-translate or by custom locale) textItemsPerPage: string; @@ -54,11 +41,32 @@ export class SlickPaginationComponent implements AfterViewInit, OnDestroy { textPage: string; /** Constructor */ - constructor(private filterService: FilterService, private gridService: GridService, @Optional() private translate: TranslateService) { + constructor(private paginationService: PaginationService, @Optional() private translate: TranslateService) { // translate all the text using ngx-translate or custom locales if (translate && translate.onLangChange) { this.subscriptions.push(this.translate.onLangChange.subscribe(() => this.translateAllUiTexts(this._locales))); } + + // translate all the text using ngx-translate or custom locales + this.paginationService.onPaginationRefreshed.subscribe(() => this.translateAllUiTexts(this._locales)); + + this.paginationService.onPaginationChanged.subscribe(pager => { + this._pager = pager; + + // emit the changes to the parent component with only necessary properties + if (!this._isFirstRender) { + this.onPaginationChanged.emit({ + pageNumber: this.pager.pageNumber, + pageSizes: this.pager.availablePageSizes, + pageSize: this.pager.itemsPerPage, + totalItems: this.pager.totalItems, + }); + } + }); + } + + get pager(): Pager { + return this._pager || this.paginationService.pager; } ngOnDestroy() { @@ -69,182 +77,56 @@ export class SlickPaginationComponent implements AfterViewInit, OnDestroy { if (this._gridPaginationOptions && this._gridPaginationOptions.enableTranslate && !this.translate) { throw new Error('[Angular-Slickgrid] requires "ngx-translate" to be installed and configured when the grid option "enableTranslate" is enabled.'); } + // get locales provided by user in forRoot or else use default English locales via the Constants + this._locales = this._gridPaginationOptions && this._gridPaginationOptions.locales || Constants.locales; - if (!this._gridPaginationOptions || !this._gridPaginationOptions.pagination || (this._gridPaginationOptions.pagination.totalItems !== this.totalItems)) { - this.refreshPagination(); - } - - // Subscribe to Filter Clear & Changed and go back to page 1 when that happen - this.subscriptions.push(this.filterService.onFilterChanged.subscribe(() => this.refreshPagination(true))); - this.subscriptions.push(this.filterService.onFilterCleared.subscribe(() => this.refreshPagination(true))); - - // 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) { - this.subscriptions.push(this.gridService.onItemAdded.subscribe((items: any | any[]) => this.onItemAddedOrRemoved(items, true))); - this.subscriptions.push(this.gridService.onItemDeleted.subscribe((items: any | any[]) => this.onItemAddedOrRemoved(items, false))); - } - } - - ceil(number: number) { - return Math.ceil(number); + this.paginationService.init(this.grid, this.dataView, this._gridPaginationOptions); } changeToFirstPage(event: any) { - this.pageNumber = 1; - this.onPageChanged(event, this.pageNumber); + this.paginationService.goToFirstPage(event); } changeToLastPage(event: any) { - this.pageNumber = this.pageCount; - this.onPageChanged(event, this.pageNumber); + this.paginationService.goToLastPage(event); } changeToNextPage(event: any) { - if (this.pageNumber < this.pageCount) { - this.pageNumber++; - this.onPageChanged(event, this.pageNumber); - } + this.paginationService.goToNextPage(event); } changeToPreviousPage(event: any) { - if (this.pageNumber > 0) { - this.pageNumber--; - this.onPageChanged(event, this.pageNumber); - } + this.paginationService.goToPreviousPage(event); } changeToCurrentPage(event: any) { - this.pageNumber = +event.currentTarget.value; - if (this.pageNumber < 1) { - this.pageNumber = 1; - } else if (this.pageNumber > this.pageCount) { - this.pageNumber = this.pageCount; + let pageNumber = 1; + if (event && event.currentTarget && event.currentTarget.value) { + pageNumber = +(event.currentTarget.value); } + this.paginationService.goToPageNumber(pageNumber, event); + } - this.onPageChanged(event, this.pageNumber); + changeItemPerPage(event: any) { + let itemsPerPage = 1; + if (event && event.currentTarget && event.currentTarget.value) { + itemsPerPage = +(event.currentTarget.value); + } + this.paginationService.changeItemPerPage(itemsPerPage, event); } dispose() { this.onPaginationChanged.unsubscribe(); - - // unsubscribe all SlickGrid events - this._eventHandler.unsubscribeAll(); + this.paginationService.dispose(); // also unsubscribe all Angular Subscriptions this.subscriptions = unsubscribeAllObservables(this.subscriptions); } - onChangeItemPerPage(event: any) { - const itemsPerPage = +event.target.value; - this.pageCount = Math.ceil(this.totalItems / itemsPerPage); - this.pageNumber = (this.totalItems > 0) ? 1 : 0; - this.itemsPerPage = itemsPerPage; - this.onPageChanged(event, this.pageNumber); - } - - refreshPagination(isPageNumberReset: boolean = false) { - const backendApi = this._gridPaginationOptions.backendServiceApi; - if (!backendApi || !backendApi.service || !backendApi.process) { - throw new Error(`BackendServiceApi requires at least a "process" function and a "service" defined`); - } - - // get locales provided by user in forRoot or else use default English locales via the Constants - this._locales = this._gridPaginationOptions && this._gridPaginationOptions.locales || Constants.locales; - - // translate all the text using ngx-translate or custom locales - this.translateAllUiTexts(this._locales); - - if (this._gridPaginationOptions && this._gridPaginationOptions.pagination) { - const pagination = this._gridPaginationOptions.pagination; - // set the number of items per page if not already set - if (!this.itemsPerPage) { - this.itemsPerPage = +((backendApi && backendApi.options && backendApi.options.paginationOptions && backendApi.options.paginationOptions.first) ? backendApi.options.paginationOptions.first : this._gridPaginationOptions.pagination.pageSize); - } - - // if totalItems changed, we should always go back to the first page and recalculation the From-To indexes - if (isPageNumberReset || this.totalItems !== pagination.totalItems) { - if (this._isFirstRender && pagination.pageNumber && pagination.pageNumber > 1) { - this.pageNumber = pagination.pageNumber || 1; - } else { - this.pageNumber = 1; - } - - // when page number is set to 1 then also reset the "offset" of backend service - if (this.pageNumber === 1) { - backendApi.service.resetPaginationOptions(); - } - } - - // calculate and refresh the multiple properties of the pagination UI - this.paginationPageSizes = this._gridPaginationOptions.pagination.pageSizes; - this.totalItems = this._gridPaginationOptions.pagination.totalItems; - this.recalculateFromToIndexes(); - } - this.pageCount = Math.ceil(this.totalItems / this.itemsPerPage); - } - - onPageChanged(event: Event | undefined, pageNumber: number) { - this.recalculateFromToIndexes(); - - const backendApi = this._gridPaginationOptions.backendServiceApi; - if (!backendApi || !backendApi.service || !backendApi.process) { - throw new Error(`BackendServiceApi requires at least a "process" function and a "service" defined`); - } - - if (this.dataTo > this.totalItems) { - this.dataTo = this.totalItems; - } else if (this.totalItems < this.itemsPerPage) { - this.dataTo = this.totalItems; - } - if (backendApi) { - try { - const itemsPerPage = +this.itemsPerPage; - - // 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(); - } - - const query = backendApi.service.processOnPaginationChanged(event, { newPage: pageNumber, pageSize: itemsPerPage }); - - // the processes can be Observables (like HttpClient) or Promises - const process = backendApi.process(query); - if (process instanceof Promise && process.then) { - process.then((processResult: GraphqlResult | any) => executeBackendProcessesCallback(startTime, processResult, backendApi, this._gridPaginationOptions)); - } else if (isObservable(process)) { - process.subscribe( - (processResult: GraphqlResult | any) => executeBackendProcessesCallback(startTime, processResult, backendApi, this._gridPaginationOptions), - (error: any) => onBackendError(error, backendApi) - ); - } - } catch (error) { - onBackendError(error, backendApi); - } - } else { - throw new Error('Pagination with a backend service requires "BackendServiceApi" to be defined in your grid options'); - } - - // emit the changes to the parent component - this.onPaginationChanged.emit({ - pageNumber: this.pageNumber, - pageSizes: this.paginationPageSizes, - pageSize: this.itemsPerPage, - totalItems: this.totalItems - }); - } - - recalculateFromToIndexes() { - if (this.totalItems === 0) { - this.dataFrom = 0; - this.dataTo = 0; - this.pageNumber = 0; - } else { - this.dataFrom = (this.pageNumber * this.itemsPerPage) - this.itemsPerPage + 1; - this.dataTo = (this.totalItems < this.itemsPerPage) ? this.totalItems : (this.pageNumber * this.itemsPerPage); + refreshPagination() { + if (this.paginationService) { + this.paginationService.gridPaginationOptions = this._gridPaginationOptions; + this.paginationService.refreshPagination(); } } @@ -266,27 +148,4 @@ export class SlickPaginationComponent implements AfterViewInit, OnDestroy { this.textPage = locales.TEXT_PAGE || 'TEXT_PAGE'; } } - - /** - * When item is added or removed, we will refresh the numbers on the pagination however we won't trigger a backend change - * This will have a side effect though, which is that the "To" count won't be matching the "items per page" count, - * that is a necessary side effect to avoid triggering a backend query just to refresh the paging, - * basically we assume that this offset is fine for the time being, - * until user does an action which will refresh the data hence the pagination which will then become normal again - */ - private onItemAddedOrRemoved(items: any | any[], isItemAdded = true) { - if (items !== null) { - const previousDataTo = this.dataTo; - const itemCount = Array.isArray(items) ? items.length : 1; - const itemCountWithDirection = isItemAdded ? +itemCount : -itemCount; - - // refresh the total count in the pagination and in the UI - this.totalItems += itemCountWithDirection; - this.recalculateFromToIndexes(); - - // finally refresh the "To" count and we know it might be different than the "items per page" count - // but this is necessary since we don't want an actual backend refresh - this.dataTo = previousDataTo + itemCountWithDirection; - } - } } diff --git a/src/app/modules/angular-slickgrid/models/angularGridInstance.interface.ts b/src/app/modules/angular-slickgrid/models/angularGridInstance.interface.ts index 61c2a4228..fc45446ec 100644 --- a/src/app/modules/angular-slickgrid/models/angularGridInstance.interface.ts +++ b/src/app/modules/angular-slickgrid/models/angularGridInstance.interface.ts @@ -7,6 +7,7 @@ import { GridEventService, GridStateService, GroupingAndColspanService, + PaginationService, ResizerService, SortService } from '../services'; @@ -54,6 +55,9 @@ export interface AngularGridInstance { /** Grouping (and colspan) Service */ groupingService: GroupingAndColspanService; + /** Pagination Service (allows you to programmatically go to first/last page, etc...) */ + paginationService: PaginationService; + /** Resizer Service (including auto-resize) */ resizerService: ResizerService; diff --git a/src/app/modules/angular-slickgrid/models/index.ts b/src/app/modules/angular-slickgrid/models/index.ts index 652f10123..9f61d5b45 100644 --- a/src/app/modules/angular-slickgrid/models/index.ts +++ b/src/app/modules/angular-slickgrid/models/index.ts @@ -93,6 +93,7 @@ export * from './odataSortingOption.interface'; export * from './onEventArgs.interface'; export * from './operatorString'; export * from './operatorType.enum'; +export * from './pager.interface'; export * from './pagination.interface'; export * from './paginationChangedArgs.interface'; export * from './queryArgument.interface'; diff --git a/src/app/modules/angular-slickgrid/models/pager.interface.ts b/src/app/modules/angular-slickgrid/models/pager.interface.ts new file mode 100644 index 000000000..80398d0fa --- /dev/null +++ b/src/app/modules/angular-slickgrid/models/pager.interface.ts @@ -0,0 +1,9 @@ +export interface Pager { + from: number; + to: number; + itemsPerPage: number; + pageCount: number; + pageNumber: number; + availablePageSizes: number[]; + totalItems: number; +} diff --git a/src/app/modules/angular-slickgrid/services/__tests__/gridState.service.spec.ts b/src/app/modules/angular-slickgrid/services/__tests__/gridState.service.spec.ts index 5019ad7d6..bbd1b86b2 100644 --- a/src/app/modules/angular-slickgrid/services/__tests__/gridState.service.spec.ts +++ b/src/app/modules/angular-slickgrid/services/__tests__/gridState.service.spec.ts @@ -1,4 +1,3 @@ -import { TestBed } from '@angular/core/testing'; import { Subject } from 'rxjs'; import { ExtensionService } from '../extension.service'; @@ -375,18 +374,18 @@ describe('GridStateService', () => { it(`should call the method with column definitions and expect "onGridStateChanged" to be triggered with "newValues" property being the columns and still empty "gridState" property`, () => { - const columnsMock = [{ id: 'field1', field: 'field1', width: 100, cssClass: 'red' }] as Column[]; - const currentColumnsMock = [{ columnId: 'field1', cssClass: 'red', headerCssClass: '', width: 100 }] as CurrentColumn[]; - const gridStateMock = { columns: [], filters: [], sorters: [] } as GridState; - const stateChangeMock = { change: { newValues: currentColumnsMock, type: GridStateType.columns }, gridState: gridStateMock } as GridStateChange; - const onChangeSpy = jest.spyOn(service.onGridStateChanged, 'next'); - const serviceSpy = jest.spyOn(service, 'getCurrentGridState').mockReturnValue(gridStateMock); + const columnsMock = [{ id: 'field1', field: 'field1', width: 100, cssClass: 'red' }] as Column[]; + const currentColumnsMock = [{ columnId: 'field1', cssClass: 'red', headerCssClass: '', width: 100 }] as CurrentColumn[]; + const gridStateMock = { columns: [], filters: [], sorters: [] } as GridState; + const stateChangeMock = { change: { newValues: currentColumnsMock, type: GridStateType.columns }, gridState: gridStateMock } as GridStateChange; + const onChangeSpy = jest.spyOn(service.onGridStateChanged, 'next'); + const serviceSpy = jest.spyOn(service, 'getCurrentGridState').mockReturnValue(gridStateMock); - service.resetColumns(columnsMock); + service.resetColumns(columnsMock); - expect(serviceSpy).toHaveBeenCalled(); - expect(onChangeSpy).toHaveBeenCalledWith(stateChangeMock); - }); + expect(serviceSpy).toHaveBeenCalled(); + expect(onChangeSpy).toHaveBeenCalledWith(stateChangeMock); + }); }); describe('resetRowSelection method', () => { diff --git a/src/app/modules/angular-slickgrid/services/__tests__/pagination.service.spec.ts b/src/app/modules/angular-slickgrid/services/__tests__/pagination.service.spec.ts new file mode 100644 index 000000000..82f5a802f --- /dev/null +++ b/src/app/modules/angular-slickgrid/services/__tests__/pagination.service.spec.ts @@ -0,0 +1,562 @@ +import { Subject, of, throwError } from 'rxjs'; + +import { PaginationService } from './../pagination.service'; +import { FilterService, GridService } from '../index'; +import { Column, GridOption, CurrentFilter } from '../../models'; +import * as utilities from '../backend-utilities'; + +const mockExecuteBackendProcess = jest.fn(); +// @ts-ignore +utilities.executeBackendProcessesCallback = mockExecuteBackendProcess; + +const mockBackendError = jest.fn(); +// @ts-ignore +utilities.onBackendError = mockBackendError; + +const dataviewStub = { + onRowCountChanged: jest.fn(), + onRowsChanged: jest.fn(), +}; + +const mockBackendService = { + resetPaginationOptions: jest.fn(), + buildQuery: jest.fn(), + updateOptions: jest.fn(), + processOnFilterChanged: jest.fn(), + processOnSortChanged: jest.fn(), + processOnPaginationChanged: jest.fn(), +}; + +const mockGridOption = { + enableAutoResize: true, + backendServiceApi: { + service: mockBackendService, + process: jest.fn(), + options: { + columnDefinitions: [{ id: 'name', field: 'name' }] as Column[], + datasetName: 'user', + } + }, + pagination: { + pageSizes: [10, 15, 20, 25, 30, 40, 50, 75, 100], + pageSize: 25, + totalItems: 85 + } +} as GridOption; + +const gridStub = { + autosizeColumns: jest.fn(), + getColumnIndex: jest.fn(), + getOptions: () => mockGridOption, + getColumns: jest.fn(), + setColumns: jest.fn(), + onColumnsReordered: jest.fn(), + onColumnsResized: jest.fn(), + registerPlugin: jest.fn(), +}; + +const filterServiceStub = { + clearFilters: jest.fn(), + onFilterChanged: new Subject(), + onFilterCleared: new Subject(), +} as unknown as FilterService; + +const gridServiceStub = { + resetColumns: jest.fn(), + onItemAdded: new Subject(), + onItemDeleted: new Subject(), +} as unknown as GridService; + +describe('PaginationService', () => { + let service: PaginationService; + + beforeEach(() => { + service = new PaginationService(filterServiceStub, gridServiceStub); + }); + + afterEach(() => { + mockGridOption.pagination.pageSize = 25; + mockGridOption.pagination.pageNumber = 2; + mockGridOption.pagination.totalItems = 85; + service.dispose(); + jest.clearAllMocks(); + }); + + it('should create the service', () => { + expect(service).toBeTruthy(); + }); + + it('should initialize the service and call "refreshPagination" and trigger "onPaginationChanged" event', () => { + const refreshSpy = jest.spyOn(service, 'refreshPagination'); + const paginationSpy = jest.spyOn(service.onPaginationChanged, 'next'); + service.init(gridStub, dataviewStub, mockGridOption); + + expect(service.gridPaginationOptions).toEqual(mockGridOption); + expect(service.pager).toBeTruthy(); + expect(refreshSpy).toHaveBeenCalled(); + expect(service.getCurrentPageNumber()).toBe(2); + expect(paginationSpy).toHaveBeenCalledWith({ + from: 26, to: 50, itemsPerPage: 25, pageCount: 4, pageNumber: 2, totalItems: 85, availablePageSizes: mockGridOption.pagination.pageSizes + }); + }); + + 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, dataviewStub, mockGridOptionCopy); + service.gridPaginationOptions = mockGridOption; + + expect(service.gridPaginationOptions).toEqual(mockGridOption); + expect(service.pager).toBeTruthy(); + expect(service.getCurrentPageNumber()).toBe(2); + }); + + describe('changeItemPerPage method', () => { + it('should be on page 0 when total items is 0', () => { + mockGridOption.pagination.totalItems = 0; + service.init(gridStub, dataviewStub, mockGridOption); + service.changeItemPerPage(30); + + expect(service.getCurrentPageNumber()).toBe(0); + expect(service.getCurrentItemPerPageCount()).toBe(30); + }); + + it('should be on page 1 with 2 pages when total items is 51 and we set 50 per page', () => { + mockGridOption.pagination.pageSize = 25; + mockGridOption.pagination.pageNumber = 2; + mockGridOption.pagination.totalItems = 51; + + service.init(gridStub, dataviewStub, mockGridOption); + service.changeItemPerPage(50); + + expect(service.getCurrentPageNumber()).toBe(1); + expect(service.getCurrentItemPerPageCount()).toBe(50); + }); + + it('should be on page 1 with 2 pages when total items is 100 and we set 50 per page', () => { + mockGridOption.pagination.pageSize = 25; + mockGridOption.pagination.pageNumber = 2; + mockGridOption.pagination.totalItems = 100; + + service.init(gridStub, dataviewStub, mockGridOption); + service.changeItemPerPage(50); + + expect(service.getCurrentPageNumber()).toBe(1); + expect(service.getCurrentItemPerPageCount()).toBe(50); + }); + }); + + describe('goToFirstPage method', () => { + it('should expect current page to be 1 and "processOnPageChanged" method to be called', () => { + const spy = jest.spyOn(service, 'processOnPageChanged'); + service.init(gridStub, dataviewStub, mockGridOption); + service.goToFirstPage(); + + expect(service.pager.from).toBe(1); + expect(service.pager.to).toBe(25); + expect(service.getCurrentPageNumber()).toBe(1); + expect(spy).toHaveBeenCalledWith(1, undefined); + }); + }); + + describe('goToLastPage method', () => { + it('should call "goToLastPage" method and expect current page to be last page and "processOnPageChanged" method to be called', () => { + const spy = jest.spyOn(service, 'processOnPageChanged'); + + service.init(gridStub, dataviewStub, mockGridOption); + service.goToLastPage(); + + expect(service.pager.from).toBe(76); + expect(service.pager.to).toBe(85); + expect(service.getCurrentPageNumber()).toBe(4); + expect(spy).toHaveBeenCalledWith(4, undefined); + }); + }); + + describe('goToNextPage method', () => { + it('should expect page to increment by 1 and "processOnPageChanged" method to be called', () => { + const spy = jest.spyOn(service, 'processOnPageChanged'); + + service.init(gridStub, dataviewStub, mockGridOption); + service.goToNextPage(); + + expect(service.pager.from).toBe(51); + expect(service.pager.to).toBe(75); + expect(service.getCurrentPageNumber()).toBe(3); + expect(spy).toHaveBeenCalledWith(3, undefined); + }); + + it('should expect page to increment by 1 and "processOnPageChanged" method to be called', () => { + const spy = jest.spyOn(service, 'processOnPageChanged'); + + service.init(gridStub, dataviewStub, mockGridOption); + service.goToNextPage(); + + expect(service.pager.from).toBe(51); + expect(service.pager.to).toBe(75); + expect(service.getCurrentPageNumber()).toBe(3); + expect(spy).toHaveBeenCalledWith(3, undefined); + }); + + it('should not expect "processOnPageChanged" method to be called when we are already on last page', () => { + const spy = jest.spyOn(service, 'processOnPageChanged'); + mockGridOption.pagination.pageNumber = 4; + + service.init(gridStub, dataviewStub, mockGridOption); + service.goToNextPage(); + + expect(service.pager.from).toBe(76); + expect(service.pager.to).toBe(85); + expect(service.getCurrentPageNumber()).toBe(4); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('goToPreviousPage method', () => { + it('should expect page to decrement by 1 and "processOnPageChanged" method to be called', () => { + const spy = jest.spyOn(service, 'processOnPageChanged'); + + service.init(gridStub, dataviewStub, mockGridOption); + service.goToPreviousPage(); + + expect(service.pager.from).toBe(1); + expect(service.pager.to).toBe(25); + expect(service.getCurrentPageNumber()).toBe(1); + expect(spy).toHaveBeenCalledWith(1, undefined); + }); + + it('should not expect "processOnPageChanged" method to be called when we are already on first page', () => { + const spy = jest.spyOn(service, 'processOnPageChanged'); + mockGridOption.pagination.pageNumber = 1; + + service.init(gridStub, dataviewStub, mockGridOption); + service.goToPreviousPage(); + + expect(service.pager.from).toBe(1); + expect(service.pager.to).toBe(25); + expect(service.getCurrentPageNumber()).toBe(1); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + + describe('goToPageNumber', () => { + it('should expect page to decrement by 1 and "processOnPageChanged" method to be called', () => { + const spy = jest.spyOn(service, 'processOnPageChanged'); + + service.init(gridStub, dataviewStub, mockGridOption); + service.goToPageNumber(4); + + expect(service.pager.from).toBe(76); + expect(service.pager.to).toBe(85); + expect(service.getCurrentPageNumber()).toBe(4); + expect(spy).toHaveBeenCalledWith(4, undefined); + }); + + it('should expect to go to page 1 when input number is below 1', () => { + const spy = jest.spyOn(service, 'processOnPageChanged'); + + service.init(gridStub, dataviewStub, mockGridOption); + service.goToPageNumber(0); + + expect(service.pager.from).toBe(1); + expect(service.pager.to).toBe(25); + expect(service.getCurrentPageNumber()).toBe(1); + expect(spy).toHaveBeenCalledWith(1, undefined); + }); + + it('should expect to go to last page (4) when input number is bigger than the last page number', () => { + const spy = jest.spyOn(service, 'processOnPageChanged'); + + service.init(gridStub, dataviewStub, mockGridOption); + service.goToPageNumber(10); + + expect(service.pager.from).toBe(76); + expect(service.pager.to).toBe(85); + expect(service.getCurrentPageNumber()).toBe(4); + expect(spy).toHaveBeenCalledWith(4, undefined); + }); + + it('should not expect "processOnPageChanged" method to be called when we are already on same page', () => { + const spy = jest.spyOn(service, 'processOnPageChanged'); + mockGridOption.pagination.pageNumber = 2; + + service.init(gridStub, dataviewStub, mockGridOption); + service.goToPageNumber(2); + + expect(service.pager.from).toBe(26); + expect(service.pager.to).toBe(50); + expect(service.getCurrentPageNumber()).toBe(2); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('processOnPageChanged method', () => { + beforeEach(() => { + mockGridOption.backendServiceApi = { + service: mockBackendService, + process: jest.fn(), + options: { + columnDefinitions: [{ id: 'name', field: 'name' }] as Column[], + datasetName: 'user', + } + }; + }); + + it('should throw an error when no backendServiceApi is provided', async () => { + service.init(gridStub, dataviewStub, mockGridOption); + mockGridOption.backendServiceApi = null; + + await expect(service.processOnPageChanged(1)).rejects.toThrowError(`BackendServiceApi requires the following 2 properties "process" and "service" to be defined.`); + }); + + it('should execute "preProcess" method when defined', () => { + const spy = jest.fn(); + mockGridOption.backendServiceApi.preProcess = spy; + + service.init(gridStub, dataviewStub, mockGridOption); + service.processOnPageChanged(1); + + expect(spy).toHaveBeenCalled(); + }); + + it('should execute "process" method and catch error when process Promise rejects', async () => { + const mockError = { error: '404' }; + const postSpy = jest.fn(); + mockGridOption.backendServiceApi.process = postSpy; + jest.spyOn(mockBackendService, 'processOnPaginationChanged').mockReturnValue('backend query'); + const promise = new Promise((resolve, reject) => setTimeout(() => reject(mockError), 1)); + jest.spyOn(mockGridOption.backendServiceApi, 'process').mockReturnValue(promise); + + try { + service.init(gridStub, dataviewStub, mockGridOption); + await service.processOnPageChanged(1); + } catch (e) { + expect(mockBackendError).toHaveBeenCalledWith(mockError, mockGridOption.backendServiceApi); + } + }); + + it('should execute "process" method and catch error when process Observable fails', async () => { + const mockError = { error: '404' }; + const postSpy = jest.fn(); + mockGridOption.backendServiceApi.process = postSpy; + jest.spyOn(mockBackendService, 'processOnPaginationChanged').mockReturnValue('backend query'); + jest.spyOn(mockGridOption.backendServiceApi, 'process').mockReturnValue(throwError(mockError)); + + try { + service.init(gridStub, dataviewStub, mockGridOption); + await service.processOnPageChanged(1); + } catch (e) { + expect(mockBackendError).toHaveBeenCalledWith(mockError, mockGridOption.backendServiceApi); + } + }); + + it('should execute "process" method when defined', (done) => { + const postSpy = jest.fn(); + mockGridOption.backendServiceApi.process = postSpy; + jest.spyOn(mockBackendService, 'processOnPaginationChanged').mockReturnValue('backend query'); + const now = new Date(); + const processResult = { users: [{ name: 'John' }], metrics: { startTime: now, endTime: now, executionTime: 0, totalItemCount: 0 } }; + const promise = new Promise((resolve) => setTimeout(() => resolve(processResult), 1)); + jest.spyOn(mockGridOption.backendServiceApi, 'process').mockReturnValue(promise); + + service.init(gridStub, dataviewStub, mockGridOption); + service.processOnPageChanged(1); + + setTimeout(() => { + expect(postSpy).toHaveBeenCalled(); + expect(mockExecuteBackendProcess).toHaveBeenCalledWith(expect.toBeDate(), processResult, mockGridOption.backendServiceApi, mockGridOption); + done(); + }); + }); + + it('should execute "process" method when defined', (done) => { + const postSpy = jest.fn(); + mockGridOption.backendServiceApi.process = postSpy; + jest.spyOn(mockBackendService, 'processOnPaginationChanged').mockReturnValue('backend query'); + const now = new Date(); + const processResult = { users: [{ name: 'John' }], metrics: { startTime: now, endTime: now, executionTime: 0, totalItemCount: 0 } }; + jest.spyOn(mockGridOption.backendServiceApi, 'process').mockReturnValue(of(processResult)); + + service.init(gridStub, dataviewStub, mockGridOption); + service.processOnPageChanged(1); + + setTimeout(() => { + expect(postSpy).toHaveBeenCalled(); + expect(mockExecuteBackendProcess).toHaveBeenCalledWith(expect.toBeDate(), processResult, mockGridOption.backendServiceApi, mockGridOption); + done(); + }); + }); + }); + + describe('recalculateFromToIndexes method', () => { + it('should recalculate the From/To as 0 when total items is 0', () => { + mockGridOption.pagination.pageSize = 25; + mockGridOption.pagination.pageNumber = 2; + mockGridOption.pagination.totalItems = 0; + + service.init(gridStub, dataviewStub, mockGridOption); + service.recalculateFromToIndexes(); + + expect(service.pager.from).toBe(0); + expect(service.pager.to).toBe(0); + }); + + it('should recalculate the From/To within range', () => { + mockGridOption.pagination.pageSize = 25; + mockGridOption.pagination.pageNumber = 2; + mockGridOption.pagination.totalItems = 85; + + service.init(gridStub, dataviewStub, mockGridOption); + service.recalculateFromToIndexes(); + + expect(service.pager.from).toBe(26); + expect(service.pager.to).toBe(50); + }); + + it('should recalculate the From/To within range and have the To equal the total items when total items is not a modulo of 1', () => { + mockGridOption.pagination.pageSize = 25; + mockGridOption.pagination.pageNumber = 4; + mockGridOption.pagination.totalItems = 85; + + service.init(gridStub, dataviewStub, mockGridOption); + service.recalculateFromToIndexes(); + + expect(service.pager.from).toBe(76); + expect(service.pager.to).toBe(85); + }); + }); + + describe('refreshPagination method', () => { + beforeEach(() => { + mockGridOption.backendServiceApi = { + service: mockBackendService, + process: jest.fn(), + options: { + columnDefinitions: [{ id: 'name', field: 'name' }] as Column[], + datasetName: 'user', + } + }; + }); + + it('should throw an error when no backendServiceApi is provided', (done) => { + try { + mockGridOption.backendServiceApi = null; + service.init(gridStub, dataviewStub, mockGridOption); + service.refreshPagination(); + } catch (e) { + expect(e.toString()).toContain(`BackendServiceApi requires the following 2 properties "process" and "service" to be defined.`); + done(); + } + }); + + it('should call refreshPagination when "onFilterCleared" is triggered', () => { + const spy = jest.spyOn(service, 'refreshPagination'); + + service.init(gridStub, dataviewStub, mockGridOption); + filterServiceStub.onFilterCleared.next(true); + + expect(spy).toHaveBeenCalledWith(true); + }); + + it('should call refreshPagination when "onFilterChanged" is triggered', () => { + const spy = jest.spyOn(service, 'refreshPagination'); + + service.init(gridStub, dataviewStub, mockGridOption); + filterServiceStub.onFilterChanged.next([{ columnId: 'field1', operator: '=', searchTerms: [] }]); + + expect(spy).toHaveBeenCalledWith(true); + }); + }); + + // processOnItemAddedOrRemoved is private but we can spy on recalculateFromToIndexes + describe('processOnItemAddedOrRemoved private method', () => { + afterEach(() => { + mockGridOption.pagination.pageSize = 25; + mockGridOption.pagination.pageNumber = 2; + mockGridOption.pagination.totalItems = 85; + jest.clearAllMocks(); + }); + + it('should call "processOnItemAddedOrRemoved" and expect the (To) to be incremented by 1 when "onItemAdded" is triggered with a single item', (done) => { + const mockItems = { name: 'John' }; + const spy = jest.spyOn(service, 'recalculateFromToIndexes'); + + service.init(gridStub, dataviewStub, mockGridOption); + gridServiceStub.onItemAdded.next(mockItems); + + setTimeout(() => { + expect(spy).toHaveBeenCalled(); + expect(service.pager.from).toBe(26); + expect(service.pager.to).toBe(50 + 1); + done(); + }); + }); + + it('should call "processOnItemAddedOrRemoved" and expect the (To) to be incremented by 2 when "onItemAdded" is triggered with an array of 2 new items', (done) => { + const mockItems = [{ name: 'John' }, { name: 'Jane' }]; + const spy = jest.spyOn(service, 'recalculateFromToIndexes'); + + service.init(gridStub, dataviewStub, mockGridOption); + gridServiceStub.onItemAdded.next(mockItems); + + setTimeout(() => { + expect(spy).toHaveBeenCalled(); + expect(service.pager.from).toBe(26); + expect(service.pager.to).toBe(50 + mockItems.length); + done(); + }); + }); + + it('should call "processOnItemAddedOrRemoved" and expect the (To) to remain the same when "onItemAdded" is triggered without any items', (done) => { + service.init(gridStub, dataviewStub, mockGridOption); + gridServiceStub.onItemAdded.next(null); + + setTimeout(() => { + expect(service.pager.from).toBe(26); + expect(service.pager.to).toBe(50); + done(); + }); + }); + + it('should call "processOnItemAddedOrRemoved" and expect the (To) to be decremented by 2 when "onItemDeleted" is triggered with a single item', (done) => { + const mockItems = { name: 'John' }; + const spy = jest.spyOn(service, 'recalculateFromToIndexes'); + + service.init(gridStub, dataviewStub, mockGridOption); + gridServiceStub.onItemDeleted.next(mockItems); + + setTimeout(() => { + expect(spy).toHaveBeenCalled(); + expect(service.pager.from).toBe(26); + expect(service.pager.to).toBe(50 - 1); + done(); + }); + }); + + it('should call "processOnItemAddedOrRemoved" and expect the (To) to be decremented by 2 when "onItemDeleted" is triggered with an array of 2 new items', (done) => { + const mockItems = [{ name: 'John' }, { name: 'Jane' }]; + const spy = jest.spyOn(service, 'recalculateFromToIndexes'); + + service.init(gridStub, dataviewStub, mockGridOption); + gridServiceStub.onItemDeleted.next(mockItems); + + setTimeout(() => { + expect(spy).toHaveBeenCalled(); + expect(service.pager.from).toBe(26); + expect(service.pager.to).toBe(50 - mockItems.length); + done(); + }); + }); + + it('should call "processOnItemAddedOrRemoved" and expect the (To) to remain the same when "onItemDeleted" is triggered without any items', (done) => { + service.init(gridStub, dataviewStub, mockGridOption); + gridServiceStub.onItemDeleted.next(null); + + setTimeout(() => { + expect(service.pager.from).toBe(26); + expect(service.pager.to).toBe(50); + done(); + }); + }); + }); +}); diff --git a/src/app/modules/angular-slickgrid/services/graphql.service.ts b/src/app/modules/angular-slickgrid/services/graphql.service.ts index 0daf693f4..9693d1246 100644 --- a/src/app/modules/angular-slickgrid/services/graphql.service.ts +++ b/src/app/modules/angular-slickgrid/services/graphql.service.ts @@ -237,7 +237,7 @@ export class GraphqlService implements BackendService { } as GraphqlCursorPaginationOption; } else { // first, last, offset - paginationOptions = (this.options.paginationOptions || this.getInitPaginationOptions()) as GraphqlPaginationOption; + paginationOptions = ((this.options && this.options.paginationOptions) || this.getInitPaginationOptions()) as GraphqlPaginationOption; (paginationOptions as GraphqlPaginationOption).offset = 0; } @@ -446,7 +446,7 @@ export class GraphqlService implements BackendService { }; let paginationOptions; - if (this.options.isWithCursor) { + if (this.options && this.options.isWithCursor) { paginationOptions = { first: pageSize }; diff --git a/src/app/modules/angular-slickgrid/services/grid.service.ts b/src/app/modules/angular-slickgrid/services/grid.service.ts index 2e02991ff..f32f0ad0f 100644 --- a/src/app/modules/angular-slickgrid/services/grid.service.ts +++ b/src/app/modules/angular-slickgrid/services/grid.service.ts @@ -273,7 +273,7 @@ export class GridService { * Add an item (data item) to the datagrid, by default it will highlight (flashing) the inserted row but we can disable it too * @param item object which must contain a unique "id" property and any other suitable properties * @param options: provide the possibility to do certain actions after or during the upsert (highlightRow, resortGrid, selectRow, triggerEvent) - * @return rowIndex: typically index 0 + * @return rowIndex: typically index 0 when adding to position "top" or a different number when adding to the "bottom" */ addItem(item: any, options?: GridServiceInsertOption): number { options = { ...GridServiceInsertOptionDefaults, ...options }; diff --git a/src/app/modules/angular-slickgrid/services/index.ts b/src/app/modules/angular-slickgrid/services/index.ts index a61d1515a..aa9c72257 100644 --- a/src/app/modules/angular-slickgrid/services/index.ts +++ b/src/app/modules/angular-slickgrid/services/index.ts @@ -12,6 +12,7 @@ export * from './grid.service'; export * from './gridState.service'; export * from './groupingAndColspan.service'; export * from './odataQueryBuilder.service'; +export * from './pagination.service'; export * from './resizer.service'; export * from './shared.service'; export * from './sort.service'; diff --git a/src/app/modules/angular-slickgrid/services/pagination.service.ts b/src/app/modules/angular-slickgrid/services/pagination.service.ts new file mode 100644 index 000000000..5a650cfc3 --- /dev/null +++ b/src/app/modules/angular-slickgrid/services/pagination.service.ts @@ -0,0 +1,282 @@ +import { Injectable } from '@angular/core'; +import { Subscription, isObservable, Subject } from 'rxjs'; + +import { GridOption, GraphqlResult, Pager } from '../models'; +import { FilterService } from './filter.service'; +import { GridService } from './grid.service'; +import { executeBackendProcessesCallback, onBackendError } from './backend-utilities'; +import { unsubscribeAllObservables } from './utilities'; + +// using external non-typed js libraries +declare var Slick: any; + +@Injectable() +export class PaginationService { + set gridPaginationOptions(gridPaginationOptions: GridOption) { + this._gridPaginationOptions = gridPaginationOptions; + } + get gridPaginationOptions(): GridOption { + return this._gridPaginationOptions; + } + + private _dataFrom = 1; + private _dataTo = 1; + private _itemsPerPage: number; + private _pageCount = 0; + private _pageNumber = 1; + private _totalItems = 0; + private _availablePageSizes = [25, 75, 100]; + private _eventHandler = new Slick.EventHandler(); + private _gridPaginationOptions: GridOption; + private _isFirstRender = true; + private _subscriptions: Subscription[] = []; + + onPaginationRefreshed = new Subject(); + onPaginationChanged = new Subject(); + + dataView: any; + grid: any; + + /** Constructor */ + constructor(private filterService: FilterService, private gridService: GridService) { } + + get pager(): Pager { + return { + from: this._dataFrom, + to: this._dataTo, + itemsPerPage: this._itemsPerPage, + pageCount: this._pageCount, + pageNumber: this._pageNumber, + availablePageSizes: this._availablePageSizes, + totalItems: this._totalItems, + }; + } + + init(grid: any, dataView: any, gridPaginationOptions: GridOption) { + this.dataView = dataView; + this.grid = grid; + this._gridPaginationOptions = gridPaginationOptions; + + if (!this._gridPaginationOptions || !this._gridPaginationOptions.pagination || (this._gridPaginationOptions.pagination.totalItems !== this._totalItems)) { + this.refreshPagination(); + } + this._isFirstRender = false; + + // Subscribe to Filter Clear & Changed and go back to page 1 when that happen + this._subscriptions.push(this.filterService.onFilterChanged.subscribe(() => this.refreshPagination(true))); + this._subscriptions.push(this.filterService.onFilterCleared.subscribe(() => this.refreshPagination(true))); + + // 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) { + this._subscriptions.push(this.gridService.onItemAdded.subscribe((items: any | any[]) => this.processOnItemAddedOrRemoved(items, true))); + this._subscriptions.push(this.gridService.onItemDeleted.subscribe((items: any | any[]) => this.processOnItemAddedOrRemoved(items, false))); + } + } + + dispose() { + // unsubscribe all SlickGrid events + this._eventHandler.unsubscribeAll(); + + // also unsubscribe all Angular Subscriptions + this._subscriptions = unsubscribeAllObservables(this._subscriptions); + } + + getCurrentPageNumber(): number { + return this._pageNumber; + } + + getCurrentItemPerPageCount(): number { + return this._itemsPerPage; + } + + changeItemPerPage(itemsPerPage: number, event?: any): Promise { + this._pageCount = Math.ceil(this._totalItems / itemsPerPage); + this._pageNumber = (this._totalItems > 0) ? 1 : 0; + this._itemsPerPage = itemsPerPage; + return this.processOnPageChanged(this._pageNumber, event); + } + + goToFirstPage(event?: any): Promise { + this._pageNumber = 1; + return this.processOnPageChanged(this._pageNumber, event); + } + + goToLastPage(event?: any): Promise { + this._pageNumber = this._pageCount; + return this.processOnPageChanged(this._pageNumber, event); + } + + goToNextPage(event?: any): Promise { + if (this._pageNumber < this._pageCount) { + this._pageNumber++; + return this.processOnPageChanged(this._pageNumber, event); + } else { + return new Promise(resolve => resolve(false)); + } + } + + goToPageNumber(pageNumber: number, event?: any): Promise { + const previousPageNumber = this._pageNumber; + + if (pageNumber < 1) { + this._pageNumber = 1; + } else if (pageNumber > this._pageCount) { + this._pageNumber = this._pageCount; + } else { + this._pageNumber = pageNumber; + } + + if (this._pageNumber !== previousPageNumber) { + return this.processOnPageChanged(this._pageNumber, event); + } else { + return new Promise(resolve => resolve(false)); + } + } + + goToPreviousPage(event?: any): Promise { + if (this._pageNumber > 1) { + this._pageNumber--; + return this.processOnPageChanged(this._pageNumber, event); + } else { + return new Promise(resolve => resolve(false)); + } + } + + refreshPagination(isPageNumberReset: boolean = false) { + const backendApi = this._gridPaginationOptions && this._gridPaginationOptions.backendServiceApi; + if (!backendApi || !backendApi.service || !backendApi.process) { + throw new Error(`BackendServiceApi requires the following 2 properties "process" and "service" to be defined.`); + } + + // trigger an event to inform subscribers + this.onPaginationRefreshed.next(true); + + if (this._gridPaginationOptions && this._gridPaginationOptions.pagination) { + const pagination = this._gridPaginationOptions.pagination; + // set the number of items per page if not already set + if (!this._itemsPerPage) { + this._itemsPerPage = +((backendApi && backendApi.options && backendApi.options.paginationOptions && backendApi.options.paginationOptions.first) ? backendApi.options.paginationOptions.first : this._gridPaginationOptions.pagination.pageSize); + } + + // if totalItems changed, we should always go back to the first page and recalculation the From-To indexes + if (isPageNumberReset || this._totalItems !== pagination.totalItems) { + if (this._isFirstRender && pagination.pageNumber && pagination.pageNumber > 1) { + this._pageNumber = pagination.pageNumber || 1; + } else { + this._pageNumber = 1; + } + + // when page number is set to 1 then also reset the "offset" of backend service + if (this._pageNumber === 1) { + backendApi.service.resetPaginationOptions(); + } + } + + // calculate and refresh the multiple properties of the pagination UI + this._availablePageSizes = this._gridPaginationOptions.pagination.pageSizes; + this._totalItems = this._gridPaginationOptions.pagination.totalItems; + this.recalculateFromToIndexes(); + } + this._pageCount = Math.ceil(this._totalItems / this._itemsPerPage); + this.onPaginationChanged.next(this.pager); + } + + processOnPageChanged(pageNumber: number, event?: Event | undefined): Promise { + return new Promise((resolve, reject) => { + this.recalculateFromToIndexes(); + + const backendApi = this._gridPaginationOptions.backendServiceApi; + if (!backendApi || !backendApi.service || !backendApi.process) { + const error = new Error(`BackendServiceApi requires the following 2 properties "process" and "service" to be defined.`); + reject(error); + throw error; + } + + if (this._dataTo > this._totalItems) { + this._dataTo = this._totalItems; + } else if (this._totalItems < this._itemsPerPage) { + this._dataTo = this._totalItems; + } + + if (backendApi) { + const itemsPerPage = +this._itemsPerPage; + + // 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(); + } + + const query = backendApi.service.processOnPaginationChanged(event, { newPage: pageNumber, pageSize: itemsPerPage }); + + // the processes can be Promises or an Observables (like HttpClient) + const process = backendApi.process(query); + if (process instanceof Promise) { + process + .then((processResult: GraphqlResult | any) => { + resolve(executeBackendProcessesCallback(startTime, processResult, backendApi, this._gridPaginationOptions)); + }) + .catch((error) => { + onBackendError(error, backendApi); + reject(process); + }); + } else if (isObservable(process)) { + process.subscribe( + (processResult: GraphqlResult | any) => { + resolve(executeBackendProcessesCallback(startTime, processResult, backendApi, this._gridPaginationOptions)); + }, + (error: any) => { + onBackendError(error, backendApi); + reject(process); + } + ); + } + this.onPaginationChanged.next(this.pager); + } + }); + } + + recalculateFromToIndexes() { + if (this._totalItems === 0) { + this._dataFrom = 0; + this._dataTo = 0; + this._pageNumber = 0; + } else { + this._dataFrom = (this._pageNumber * this._itemsPerPage) - this._itemsPerPage + 1; + this._dataTo = (this._totalItems < this._itemsPerPage) ? this._totalItems : (this._pageNumber * this._itemsPerPage); + if (this._dataTo > this._totalItems) { + this._dataTo = this._totalItems; + } + } + } + + // -- + // private functions + // -------------------- + + /** + * When item is added or removed, we will refresh the numbers on the pagination however we won't trigger a backend change + * This will have a side effect though, which is that the "To" count won't be matching the "items per page" count, + * that is a necessary side effect to avoid triggering a backend query just to refresh the paging, + * basically we assume that this offset is fine for the time being, + * until user does an action which will refresh the data hence the pagination which will then become normal again + */ + private processOnItemAddedOrRemoved(items: any | any[], isItemAdded = true) { + if (items !== null) { + const previousDataTo = this._dataTo; + const itemCount = Array.isArray(items) ? items.length : 1; + const itemCountWithDirection = isItemAdded ? +itemCount : -itemCount; + + // refresh the total count in the pagination and in the UI + this._totalItems += itemCountWithDirection; + this.recalculateFromToIndexes(); + + // finally refresh the "To" count and we know it might be different than the "items per page" count + // but this is necessary since we don't want an actual backend refresh + this._dataTo = previousDataTo + itemCountWithDirection; + } + } +}