From 4a2bfc8cbd6ef446a884bae9ead9c10c083c6ecc Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 10 Oct 2024 23:49:50 -0400 Subject: [PATCH] feat: allow providing a Custom Pagination --- docs/TOC.md | 1 + docs/grid-functionalities/pagination.md | 57 ++++++ .../src/app-routing.ts | 2 + .../vite-demo-vanilla-bundle/src/app.html | 3 + .../src/examples/example30-pager.scss | 51 +++++ .../src/examples/example30-pager.ts | 192 ++++++++++++++++++ .../src/examples/example30.html | 34 ++++ .../src/examples/example30.ts | 163 +++++++++++++++ .../src/interfaces/gridOption.interface.ts | 4 + packages/common/src/interfaces/index.ts | 1 - .../src/interfaces/pagination.interface.ts | 15 +- .../interfaces/servicePagination.interface.ts | 12 -- .../common/src/services/pagination.service.ts | 18 +- .../src/slick-pagination.component.ts | 6 +- .../__tests__/slick-vanilla-grid.spec.ts | 12 +- .../components/slick-vanilla-grid-bundle.ts | 18 +- test/cypress/e2e/example30.cy.ts | 77 +++++++ 17 files changed, 625 insertions(+), 41 deletions(-) create mode 100644 docs/grid-functionalities/pagination.md create mode 100644 examples/vite-demo-vanilla-bundle/src/examples/example30-pager.scss create mode 100644 examples/vite-demo-vanilla-bundle/src/examples/example30-pager.ts create mode 100644 examples/vite-demo-vanilla-bundle/src/examples/example30.html create mode 100644 examples/vite-demo-vanilla-bundle/src/examples/example30.ts delete mode 100644 packages/common/src/interfaces/servicePagination.interface.ts create mode 100644 test/cypress/e2e/example30.cy.ts diff --git a/docs/TOC.md b/docs/TOC.md index 349610912..4bd4918db 100644 --- a/docs/TOC.md +++ b/docs/TOC.md @@ -53,6 +53,7 @@ * [Grid State & Presets](grid-functionalities/grid-state-preset.md) * [Grouping & Aggregators](grid-functionalities/grouping-aggregators.md) * [Header Menu & Header Buttons](grid-functionalities/header-menu-header-buttons.md) +* [Pagination](grid-functionalities/pagination.md) * [Infinite Scroll](grid-functionalities/infinite-scroll.md) * [Pinning (frozen) of Columns/Rows](grid-functionalities/frozen-columns-rows.md) * [Row Selection](grid-functionalities/Row-Selection.md) diff --git a/docs/grid-functionalities/pagination.md b/docs/grid-functionalities/pagination.md new file mode 100644 index 000000000..3bdac51e2 --- /dev/null +++ b/docs/grid-functionalities/pagination.md @@ -0,0 +1,57 @@ +### Introduction +The project has a built-in Pagination Component but in some cases, users might want to provide their own Custom Pagination Component. + +### Demo +[Demo Page](https://ghiscoding.github.io/slickgrid-universal/#/example30) / [Demo Component](https://github.com/ghiscoding/slickgrid-universal/blob/master/examples/webpack-demo-vanilla-bundle/src/examples/example30.ts) + +#### Custom Pagination +When providing a custom pagination component as a `customPaginationComponent`, that class will be instantiated instead of the regular `SlickPaginationComponent`. + +> **Note** Your Custom Pagination must `implements BasePaginationComponent` so that the internal instantiation work as intended. + +##### Component + +```ts +import { CustomPager } from './custom-pager'; + +export class GridBasicComponent { + columnDefinitions: Column[]; + gridOptions: GridOption; + dataset: any[]; + + attached(): void { + // your columns definition + this.columnDefinitions = []; + + this.gridOptions = { + // enable pagination and provide a `customPaginationComponent` + enablePagination: true, + customPaginationComponent: CustomPager, + + // provide any of the regular pagination options like usual + pagination: { + pageSize: this.pageSize + }, + } + } +} +``` + +###### Custom Pagination Component +```ts +import type { BasePaginationComponent, PaginationService, PubSubService, SlickGrid } from '@slickgrid-universal/common'; + +export class CustomPager implements BasePaginationComponent { + constructor(protected readonly grid: SlickGrid, protected readonly paginationService: PaginationService, protected readonly pubSubService: PubSubService) { + // ... + } + + dispose() { + // ... + } + + render(containerElm: HTMLElement) { + // ... + } +} +``` diff --git a/examples/vite-demo-vanilla-bundle/src/app-routing.ts b/examples/vite-demo-vanilla-bundle/src/app-routing.ts index ab49239f4..e9cc9e749 100644 --- a/examples/vite-demo-vanilla-bundle/src/app-routing.ts +++ b/examples/vite-demo-vanilla-bundle/src/app-routing.ts @@ -30,6 +30,7 @@ import Example26 from './examples/example26'; import Example27 from './examples/example27'; import Example28 from './examples/example28'; import Example29 from './examples/example29'; +import Example30 from './examples/example30'; export class AppRouting { constructor(private config: RouterConfig) { @@ -65,6 +66,7 @@ export class AppRouting { { route: 'example27', name: 'example27', view: './examples/example27.html', viewModel: Example27, title: 'Example27', }, { route: 'example28', name: 'example28', view: './examples/example28.html', viewModel: Example28, title: 'Example28', }, { route: 'example29', name: 'example29', view: './examples/example29.html', viewModel: Example29, title: 'Example29', }, + { route: 'example30', name: 'example30', view: './examples/example30.html', viewModel: Example30, title: 'Example30', }, { 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 53354bad5..d74173e96 100644 --- a/examples/vite-demo-vanilla-bundle/src/app.html +++ b/examples/vite-demo-vanilla-bundle/src/app.html @@ -123,6 +123,9 @@

Slickgrid-Universal

Example29 - Drag & Drop + + Example30 - Custom Pagination + diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example30-pager.scss b/examples/vite-demo-vanilla-bundle/src/examples/example30-pager.scss new file mode 100644 index 000000000..9c25ab028 --- /dev/null +++ b/examples/vite-demo-vanilla-bundle/src/examples/example30-pager.scss @@ -0,0 +1,51 @@ +@use 'sass:color'; + +.custom-pagination { + display: flex; + justify-content: flex-end; + margin: 10px; + font-size: 13px; + + .custom-pagination-settings { + display: inline-flex; + align-items: center; + margin-right: 30px; + } + + .custom-pagination-nav { + display: flex; + align-items: center; + + .page-item { + display: flex; + width: 26px; + justify-content: center; + margin: 0; + &.disabled .page-link { + color: rgb(180, 179, 179); + } + } + + .page-number { + padding: 0 5px; + .page-number { + display: inline-flex; + justify-content: center; + width: 20px; + } + } + + nav { + ul { + display: flex; + + .page-link { + color: rgb(0, 168, 53); + &:hover { + color: color.adjust(rgb(0, 168, 53), $lightness: -12%); + } + } + } + } + } +} \ No newline at end of file diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example30-pager.ts b/examples/vite-demo-vanilla-bundle/src/examples/example30-pager.ts new file mode 100644 index 000000000..9ad988173 --- /dev/null +++ b/examples/vite-demo-vanilla-bundle/src/examples/example30-pager.ts @@ -0,0 +1,192 @@ +import { BindingEventService, BindingHelper } from '@slickgrid-universal/binding'; +import type { BasePaginationComponent, PaginationService, PubSubService, PaginationMetadata, SlickGrid, Subscription } from '@slickgrid-universal/common'; + +import './example30-pager.scss'; + +export class CustomPager implements BasePaginationComponent { + protected _bindingHelper: BindingHelper; + protected _bindingEventService: BindingEventService; + protected _paginationElement!: HTMLDivElement; + protected _subscriptions: Subscription[] = []; + protected _gridContainerElm?: HTMLElement; + currentPagination: PaginationMetadata; + firstButtonClasses = 'li page-item seek-first'; + prevButtonClasses = 'li page-item seek-prev'; + lastButtonClasses = 'li page-item seek-end'; + nextButtonClasses = 'li page-item seek-next'; + + constructor(protected readonly grid: SlickGrid, protected readonly paginationService: PaginationService, protected readonly pubSubService: PubSubService) { + this._bindingHelper = new BindingHelper(); + this._bindingEventService = new BindingEventService(); + + // Anytime the pagination is initialized or has changes, + // we'll copy the data into a local object so that we can add binding to this local object + this._subscriptions.push( + this.pubSubService.subscribe('onPaginationRefreshed', paginationChanges => { + this.currentPagination.dataFrom = paginationChanges.dataFrom; + this.currentPagination.dataTo = paginationChanges.dataTo; + this.currentPagination.pageCount = paginationChanges.pageCount; + this.currentPagination.pageNumber = paginationChanges.pageNumber; + this.currentPagination.pageSize = paginationChanges.pageSize; + this.currentPagination.pageSizes = paginationChanges.pageSizes; + this.currentPagination.totalItems = paginationChanges.totalItems; + + this.updatePageButtonsUsability(); + }) + ); + } + + dispose() { + this.pubSubService.unsubscribeAll(this._subscriptions); + this.disposeElement(); + } + + disposeElement() { + this._bindingEventService.unbindAll(); + this._bindingHelper.dispose(); + this._paginationElement.remove(); + } + + render(containerElm: HTMLElement, position: 'top' | 'bottom' = 'top') { + this._gridContainerElm = containerElm; + this.currentPagination = this.paginationService.getFullPagination(); + this._paginationElement = document.createElement('div'); + this._paginationElement.id = 'pager'; + this._paginationElement.className = `pagination-container pager ${this.grid.getUID()}`; + this._paginationElement.style.width = '100%'; + this._paginationElement.innerHTML = + `
+ + + + + ${this.currentPagination.dataFrom} + - + + ${this.currentPagination.dataTo} + + of + + + ${this.currentPagination.totalItems} + items + + + +
+ +
+ Page + ${this.currentPagination.pageNumber} + of + ${this.currentPagination.pageCount} +
+ +
+
`; + + if (position === 'top') { + // we can prepend the grid if we wish + this._paginationElement.classList.add('top'); + containerElm.prepend(this._paginationElement); + } else { + // or append it at the bottom + this._paginationElement.classList.add('bottom'); + containerElm.appendChild(this._paginationElement); + } + + // button usabilities (which buttons are disabled/enabled) + this.updatePageButtonsUsability(); + + // value/classes bindings + this.addBindings(); + + // event listeners + this.addEventListeners(this._paginationElement); + } + + /** + * Add some DOM Element bindings, typically the framework you choose will do this (i.e. Angular/React/...) + * but we're in plain JS here so let's use simply binding service available in Slickgrid-Universal + */ + addBindings(): void { + // CSS classes + this._bindingHelper.addElementBinding(this, 'firstButtonClasses', 'li.page-item.seek-first', 'className'); + this._bindingHelper.addElementBinding(this, 'prevButtonClasses', 'li.page-item.seek-prev', 'className'); + this._bindingHelper.addElementBinding(this, 'lastButtonClasses', 'li.page-item.seek-end', 'className'); + this._bindingHelper.addElementBinding(this, 'nextButtonClasses', 'li.page-item.seek-next', 'className'); + + // span texts + this._bindingHelper.addElementBinding(this.currentPagination, 'dataFrom', 'span.item-from', 'textContent'); + this._bindingHelper.addElementBinding(this.currentPagination, 'dataTo', 'span.item-to', 'textContent'); + this._bindingHelper.addElementBinding(this.currentPagination, 'totalItems', 'span.total-items', 'textContent'); + this._bindingHelper.addElementBinding(this.currentPagination, 'pageCount', 'span.page-count', 'textContent'); + this._bindingHelper.addElementBinding(this.currentPagination, 'pageNumber', 'span.page-number', 'textContent'); + } + + /** Add some DOM Element event listeners */ + addEventListeners(containerElm: HTMLElement): void { + this._bindingEventService.bind(containerElm.querySelector('.icon-seek-first')!, 'click', this.onFirstPageClicked.bind(this) as EventListener); + this._bindingEventService.bind(containerElm.querySelector('.icon-seek-prev')!, 'click', this.onPreviousPageClicked.bind(this) as EventListener); + this._bindingEventService.bind(containerElm.querySelector('.icon-seek-next')!, 'click', this.onNextPageClicked.bind(this) as EventListener); + this._bindingEventService.bind(containerElm.querySelector('.icon-seek-end')!, 'click', this.onLastPageClicked.bind(this) as EventListener); + } + + onFirstPageClicked(event: MouseEvent): void { + if (!this.isLeftPaginationDisabled()) { + this.paginationService.goToFirstPage(event); + } + } + + onLastPageClicked(event: MouseEvent): void { + if (!this.isRightPaginationDisabled()) { + this.paginationService.goToLastPage(event); + } + } + + onNextPageClicked(event: MouseEvent): void { + if (!this.isRightPaginationDisabled()) { + this.paginationService.goToNextPage(event); + } + } + + onPreviousPageClicked(event: MouseEvent): void { + if (!this.isLeftPaginationDisabled()) { + this.paginationService.goToPreviousPage(event); + } + } + + isLeftPaginationDisabled(): boolean { + return this.currentPagination.pageNumber === 1 || this.currentPagination.totalItems === 0; + } + + isRightPaginationDisabled(): boolean { + return this.currentPagination.pageNumber === this.currentPagination.pageCount || this.currentPagination.totalItems === 0; + } + + /** button usabilities (which buttons are disabled/enabled) */ + protected updatePageButtonsUsability(): void { + this.firstButtonClasses = this.isLeftPaginationDisabled() ? 'page-item seek-first disabled' : 'page-item seek-first'; + this.prevButtonClasses = this.isLeftPaginationDisabled() ? 'page-item seek-prev disabled' : 'page-item seek-prev'; + this.lastButtonClasses = this.isRightPaginationDisabled() ? 'page-item seek-end disabled' : 'page-item seek-end'; + this.nextButtonClasses = this.isRightPaginationDisabled() ? 'page-item seek-next disabled' : 'page-item seek-next'; + } +} \ No newline at end of file diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example30.html b/examples/vite-demo-vanilla-bundle/src/examples/example30.html new file mode 100644 index 000000000..185e180fc --- /dev/null +++ b/examples/vite-demo-vanilla-bundle/src/examples/example30.html @@ -0,0 +1,34 @@ +

+ Example 30 - Custom Pagination + + +

+ + +
+ + + + Page Size + + +
+ +
+ +
+ You can create a Custom Pagination that will be appended/prepended (bottom or top) of the grid. In fact we can put these pagination elements + anywhere on the page (for example the page size is totally separate in his own corner above). +
+ +
+
\ No newline at end of file diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example30.ts b/examples/vite-demo-vanilla-bundle/src/examples/example30.ts new file mode 100644 index 000000000..9e5ebf553 --- /dev/null +++ b/examples/vite-demo-vanilla-bundle/src/examples/example30.ts @@ -0,0 +1,163 @@ +import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle'; + +import { + type Column, + FieldType, + Filters, + Formatters, + type GridOption, + type MultipleSelectOption, + OperatorType, + type SliderRangeOption, +} from '@slickgrid-universal/common'; + +import { ExampleGridOptions } from './example-grid-options'; +import { CustomPager } from './example30-pager'; + +const NB_ITEMS = 5000; + +function randomBetween(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1) + min); +} + +export default class Example30 { + pageSize = 50; + columnDefinitions: Column[] = []; + gridContainerElm: HTMLDivElement; + gridOptions!: GridOption; + dataset: any[] = []; + paginationPosition: 'bottom' | 'top' = 'top'; + sgb: SlickVanillaGridBundle; + + attached() { + // define the grid options & columns and then create the grid itself + this.defineGrid(); + + // mock some data (different in each dataset) + this.dataset = this.mockData(NB_ITEMS); + this.gridContainerElm = document.querySelector('.grid30') as HTMLDivElement; + this.sgb = new Slicker.GridBundle(this.gridContainerElm, this.columnDefinitions, { ...ExampleGridOptions, ...this.gridOptions }, this.dataset); + document.body.classList.add('material-theme'); + } + + dispose() { + document.body.classList.remove('material-theme'); + } + + /* Define grid Options and Columns */ + defineGrid() { + this.columnDefinitions = [ + { + id: 'title', name: 'Title', field: 'id', minWidth: 100, + sortable: true, + filterable: true, + formatter: (row, cell, val) => `Task ${val}`, + params: { useFormatterOuputToFilter: true } + }, + { + id: 'description', name: 'Description', field: 'description', filterable: true, sortable: true, minWidth: 80, + type: FieldType.string, + }, + { + id: 'percentComplete', name: '% Complete', field: 'percentComplete', nameKey: 'PERCENT_COMPLETE', minWidth: 120, + sortable: true, + customTooltip: { position: 'center' }, + formatter: Formatters.progressBar, + type: FieldType.number, + filterable: true, + filter: { + model: Filters.sliderRange, + maxValue: 100, // or you can use the filterOptions as well + operator: OperatorType.rangeInclusive, // defaults to inclusive + filterOptions: { + hideSliderNumbers: false, // you can hide/show the slider numbers on both side + min: 0, step: 5 + } as SliderRangeOption + } + }, + { + id: 'start', name: 'Start', field: 'start', formatter: Formatters.dateIso, sortable: true, minWidth: 75, width: 100, exportWithFormatter: true, + type: FieldType.date, filterable: true, filter: { model: Filters.compoundDate } + }, + { + id: 'finish', name: 'Finish', field: 'finish', formatter: Formatters.dateIso, sortable: true, minWidth: 75, width: 120, exportWithFormatter: true, + type: FieldType.date, + filterable: true, + filter: { + model: Filters.dateRange, + } + }, + { + id: 'duration', field: 'duration', name: 'Duration', maxWidth: 90, + type: FieldType.number, + sortable: true, + filterable: true, filter: { + model: Filters.input, + operator: OperatorType.rangeExclusive // defaults to exclusive + } + }, + { + id: 'completed', name: 'Completed', field: 'completed', minWidth: 85, maxWidth: 90, + formatter: Formatters.checkmarkMaterial, + exportWithFormatter: true, // you can set this property in the column definition OR in the grid options, column def has priority over grid options + filterable: true, + filter: { + collection: [{ value: '', label: '' }, { value: true, label: 'True' }, { value: false, label: 'False' }], + model: Filters.singleSelect, + filterOptions: { autoAdjustDropHeight: true } as MultipleSelectOption + } + } + ]; + + this.gridOptions = { + autoResize: { + container: '.demo-container', + bottomPadding: this.paginationPosition === 'top' ? -10 : 20 // use a negative bottom padding since we've prepended custom pagination + }, + enableExcelCopyBuffer: true, + enableFiltering: true, + customPaginationComponent: CustomPager, + enablePagination: true, + rowHeight: 40, + pagination: { + pageSize: this.pageSize + }, + }; + } + + setPaginationSize(pageSize: number) { + this.sgb.paginationService.changeItemPerPage(pageSize); + } + + mockData(itemCount: number, startingIndex = 0): any[] { + // mock a dataset + const tempDataset: any[] = []; + for (let i = startingIndex; i < (startingIndex + itemCount); i++) { + const randomDuration = randomBetween(0, 365); + const randomYear = randomBetween(new Date().getFullYear(), new Date().getFullYear() + 1); + const randomMonth = randomBetween(0, 12); + const randomDay = randomBetween(10, 28); + const randomPercent = randomBetween(0, 100); + + tempDataset.push({ + id: i, + title: 'Task ' + i, + description: (i % 5) ? 'desc ' + i : null, // also add some random to test NULL field + duration: randomDuration, + percentComplete: randomPercent, + percentCompleteNumber: randomPercent, + start: (i % 4) ? null : new Date(randomYear, randomMonth, randomDay), // provide a Date format + finish: new Date(randomYear, randomMonth, randomDay), + completed: (randomPercent === 100) ? true : false, + }); + } + + return tempDataset; + } + + togglePaginationPosition() { + this.paginationPosition = this.paginationPosition === 'top' ? 'bottom' : 'top'; + (this.sgb.paginationComponent as CustomPager)?.disposeElement(); + (this.sgb.paginationComponent as CustomPager)?.render(this.gridContainerElm, this.paginationPosition); + } +} diff --git a/packages/common/src/interfaces/gridOption.interface.ts b/packages/common/src/interfaces/gridOption.interface.ts index 3c475a3e5..4a2a70dc7 100644 --- a/packages/common/src/interfaces/gridOption.interface.ts +++ b/packages/common/src/interfaces/gridOption.interface.ts @@ -7,6 +7,7 @@ import type { AutoTooltipOption, AutocompleterOption, BackendServiceApi, + BasePaginationComponent, CellMenu, CheckboxSelectorOption, Column, @@ -222,6 +223,9 @@ export interface GridOption { /** Custom Footer Options */ customFooterOptions?: CustomFooterOption; + /** External Custom Pagination Component that can be provided by the user */ + customPaginationComponent?: typeof BasePaginationComponent; + /** * Custom Tooltip Options, the tooltip could be defined in any of the Column Definition or in the Grid Options, * it will first try to find it in the Column that the user is hovering over or else (when not found) go and try to find it in the Grid Options diff --git a/packages/common/src/interfaces/index.ts b/packages/common/src/interfaces/index.ts index 69e42eb93..1dfbf9657 100644 --- a/packages/common/src/interfaces/index.ts +++ b/packages/common/src/interfaces/index.ts @@ -132,7 +132,6 @@ export type * from './rowSelectionModelOption.interface'; export type * from './searchColumnFilter.interface'; export type * from './selectableOverrideCallback.interface'; export type * from './selectOption.interface'; -export type * from './servicePagination.interface'; export type * from './singleColumnSort.interface'; export type * from './slickPlugin.interface'; export type * from './slickRemoteModel.interface'; diff --git a/packages/common/src/interfaces/pagination.interface.ts b/packages/common/src/interfaces/pagination.interface.ts index 9716158b8..dee65ea39 100644 --- a/packages/common/src/interfaces/pagination.interface.ts +++ b/packages/common/src/interfaces/pagination.interface.ts @@ -18,7 +18,7 @@ export interface Pagination { totalItems?: number; } -export abstract class BasePaginationComponent { +export class BasePaginationComponent { constructor( _grid: SlickGrid, _paginationService: PaginationService, @@ -29,4 +29,15 @@ export abstract class BasePaginationComponent { dispose(): void { } render(_containerElm: HTMLElement): void { } -} \ No newline at end of file +} + +export interface PaginationMetadata extends Pagination { + /** How many pages do we have in total to display the entire dataset? */ + pageCount?: number; + + /** Current From count (which displayed items are we starting from) */ + dataFrom?: number; + + /** Current To count (which displayed items are we ending to) */ + dataTo?: number; +} diff --git a/packages/common/src/interfaces/servicePagination.interface.ts b/packages/common/src/interfaces/servicePagination.interface.ts deleted file mode 100644 index cf7d9fea3..000000000 --- a/packages/common/src/interfaces/servicePagination.interface.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Pagination } from './pagination.interface'; - -export interface ServicePagination extends Pagination { - /** How many pages do we have in total to display the entire dataset? */ - pageCount?: number; - - /** Current From count (which displayed items are we starting from) */ - dataFrom?: number; - - /** Current To count (which displayed items are we ending to) */ - dataTo?: number; -} diff --git a/packages/common/src/services/pagination.service.ts b/packages/common/src/services/pagination.service.ts index d07b1eec1..d22a2f1dc 100644 --- a/packages/common/src/services/pagination.service.ts +++ b/packages/common/src/services/pagination.service.ts @@ -7,7 +7,7 @@ import type { CursorPageInfo, Pagination, PaginationCursorChangedArgs, - ServicePagination, + PaginationMetadata, } from '../interfaces/index'; import type { BackendUtilityService } from './backendUtility.service'; import type { SharedService } from './shared.service'; @@ -186,7 +186,7 @@ export class PaginationService { }; } - getFullPagination(): ServicePagination { + getFullPagination(): PaginationMetadata { return { pageCount: this._pageCount, pageNumber: this._pageNumber, @@ -206,14 +206,14 @@ export class PaginationService { return this._itemsPerPage; } - changeItemPerPage(itemsPerPage: number, event?: any, triggerChangeEvent = true): Promise { + changeItemPerPage(itemsPerPage: number, event?: any, triggerChangeEvent = true): Promise { this._pageNumber = 1; this._pageCount = Math.ceil(this._totalItems / itemsPerPage); this._itemsPerPage = itemsPerPage; return triggerChangeEvent ? this.processOnPageChanged(this._pageNumber, event) : Promise.resolve(this.getFullPagination()); } - goToFirstPage(event?: any, triggerChangeEvent = true): Promise { + goToFirstPage(event?: any, triggerChangeEvent = true): Promise { this._pageNumber = 1; if (triggerChangeEvent) { return this.isCursorBased && this._cursorPageInfo @@ -223,7 +223,7 @@ export class PaginationService { return Promise.resolve(this.getFullPagination()); } - goToLastPage(event?: any, triggerChangeEvent = true): Promise { + goToLastPage(event?: any, triggerChangeEvent = true): Promise { this._pageNumber = this._pageCount || 1; if (triggerChangeEvent) { return this.isCursorBased && this._cursorPageInfo @@ -233,7 +233,7 @@ export class PaginationService { return Promise.resolve(this.getFullPagination()); } - goToNextPage(event?: any, triggerChangeEvent = true): Promise { + goToNextPage(event?: any, triggerChangeEvent = true): Promise { if (this._pageNumber < this._pageCount) { this._pageNumber++; if (triggerChangeEvent) { @@ -247,7 +247,7 @@ export class PaginationService { return Promise.resolve(false); } - goToPageNumber(pageNumber: number, event?: any, triggerChangeEvent = true): Promise { + goToPageNumber(pageNumber: number, event?: any, triggerChangeEvent = true): Promise { if (this.isCursorBased) { console.assert(true, 'Cursor based navigation cannot navigate to arbitrary page'); return Promise.resolve(false); @@ -269,7 +269,7 @@ export class PaginationService { return Promise.resolve(false); } - goToPreviousPage(event?: any, triggerChangeEvent = true): Promise { + goToPreviousPage(event?: any, triggerChangeEvent = true): Promise { if (this._pageNumber > 1) { this._pageNumber--; if (triggerChangeEvent) { @@ -383,7 +383,7 @@ export class PaginationService { } } - processOnPageChanged(pageNumber: number, event?: Event | undefined, cursorArgs?: PaginationCursorChangedArgs): Promise { + processOnPageChanged(pageNumber: number, event?: Event | undefined, cursorArgs?: PaginationCursorChangedArgs): Promise { console.assert(!this.isCursorBased || cursorArgs, 'Configured for cursor based pagination - cursorArgs expected'); if (this.pubSubService.publish('onBeforePaginationChange', this.getFullPagination()) === false) { diff --git a/packages/pagination-component/src/slick-pagination.component.ts b/packages/pagination-component/src/slick-pagination.component.ts index cf2164631..fb18c4e2a 100644 --- a/packages/pagination-component/src/slick-pagination.component.ts +++ b/packages/pagination-component/src/slick-pagination.component.ts @@ -4,7 +4,7 @@ import type { BasePaginationComponent, PaginationService, PubSubService, - ServicePagination, + PaginationMetadata, SlickGrid, Subscription, TranslaterService, @@ -25,7 +25,7 @@ export class SlickPaginationComponent implements BasePaginationComponent { protected _seekNextElm!: HTMLLIElement; protected _seekEndElm!: HTMLLIElement; protected _subscriptions: Subscription[] = []; - currentPagination: ServicePagination; + currentPagination: PaginationMetadata; firstButtonClasses = ''; lastButtonClasses = ''; prevButtonClasses = ''; @@ -59,7 +59,7 @@ export class SlickPaginationComponent implements BasePaginationComponent { // Anytime the pagination is initialized or has changes, // we'll copy the data into a local object so that we can add binding to this local object this._subscriptions.push( - this.pubSubService.subscribe('onPaginationRefreshed', paginationChanges => { + this.pubSubService.subscribe('onPaginationRefreshed', paginationChanges => { for (const key of Object.keys(paginationChanges)) { (this.currentPagination as any)[key] = (paginationChanges as any)[key]; } diff --git a/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts b/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts index b18da7c9a..10330ec67 100644 --- a/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts +++ b/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts @@ -34,9 +34,9 @@ import { type OnRowsChangedEventArgs, type OnSetItemsCalledEventArgs, type Pagination, + type PaginationMetadata, type PaginationService, type ResizerService, - type ServicePagination, SharedService, SlickDataView, type SlickEditorLock, @@ -1896,20 +1896,20 @@ describe('Slick-Vanilla-Grid-Bundle Component instantiated via Constructor', () it('should call trigger a gridStage change event when "onPaginationChanged" from the Pagination Service is triggered', () => { const mockPagination = { pageNumber: 2, pageSize: 20 } as CurrentPagination; - const mockServicePagination = { + const mockPaginationMetadata = { ...mockPagination, dataFrom: 5, dataTo: 10, pageCount: 1, pageSizes: [5, 10, 15, 20], - } as ServicePagination; + } as PaginationMetadata; const pluginEaSpy = vi.spyOn(eventPubSubService, 'publish'); vi.spyOn(gridStateServiceStub, 'getCurrentGridState').mockReturnValue({ columns: [], pagination: mockPagination } as GridState); component.gridOptions.enablePagination = true; component.initialization(divContainer, slickEventHandler); component.refreshGridData([{ firstName: 'John', lastName: 'Doe' }]); - eventPubSubService.publish('onPaginationChanged', mockServicePagination); + eventPubSubService.publish('onPaginationChanged', mockPaginationMetadata); expect(pluginEaSpy).toHaveBeenCalledWith('onGridStateChanged', { change: { newValues: mockPagination, type: GridStateType.pagination }, @@ -2172,7 +2172,7 @@ describe('Slick-Vanilla-Grid-Bundle Component instantiated via Constructor', () component.initialization(divContainer, slickEventHandler); component.refreshGridData([{ firstName: 'John', lastName: 'Doe' }]); - const disposeSpy = vi.spyOn(component.slickPagination!, 'dispose'); + const disposeSpy = vi.spyOn(component.paginationComponent!, 'dispose'); eventPubSubService.publish('onPaginationVisibilityChanged', { visible: false }); expect(component.showPagination).toBeFalsy(); @@ -2193,7 +2193,7 @@ describe('Slick-Vanilla-Grid-Bundle Component instantiated via Constructor', () eventPubSubService.publish('onPaginationVisibilityChanged', { visible: true }); expect(backendRefreshSpy).toHaveBeenCalled(); - expect(component.slickPagination).toBeTruthy(); + expect(component.paginationComponent).toBeTruthy(); expect(component.showPagination).toBeTruthy(); }); }); 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 aaa57a7b6..a4ecfb03e 100644 --- a/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts +++ b/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts @@ -3,6 +3,7 @@ import type { BackendService, BackendServiceApi, BackendServiceOption, + BasePaginationComponent, Column, DataViewOption, ExtensionList, @@ -12,7 +13,7 @@ import type { Pagination, RxJsFacade, SelectEditor, - ServicePagination, + PaginationMetadata, Subscription, } from '@slickgrid-universal/common'; @@ -114,6 +115,7 @@ export class SlickVanillaGridBundle { gridService!: GridService; gridStateService!: GridStateService; headerGroupingService!: HeaderGroupingService; + paginationComponent: BasePaginationComponent | undefined; paginationService!: PaginationService; rxjs?: RxJsFacade; sharedService!: SharedService; @@ -125,7 +127,6 @@ export class SlickVanillaGridBundle { // components slickEmptyWarning: SlickEmptyWarningComponent | undefined; slickFooter: SlickFooterComponent | undefined; - slickPagination: SlickPaginationComponent | undefined; get backendService(): BackendService | undefined { return this.gridOptions.backendServiceApi?.service; @@ -442,7 +443,7 @@ export class SlickVanillaGridBundle { // dispose the Components this.slickFooter?.dispose(); this.slickEmptyWarning?.dispose(); - this.slickPagination?.dispose(); + this.paginationComponent?.dispose(); unsubscribeAll(this.subscriptions); this._eventPubSubService?.unsubscribeAll(); @@ -1036,7 +1037,7 @@ export class SlickVanillaGridBundle { * On a Pagination changed, we will trigger a Grid State changed with the new pagination info * Also if we use Row Selection or the Checkbox Selector with a Backend Service (Odata, GraphQL), we need to reset any selection */ - paginationChanged(pagination: ServicePagination): void { + paginationChanged(pagination: PaginationMetadata): void { const isSyncGridSelectionEnabled = this.gridStateService?.needToPreserveRowSelection() ?? false; if (this.slickGrid && !isSyncGridSelectionEnabled && this._gridOptions?.backendServiceApi && (this.gridOptions.enableRowSelection || this.gridOptions.enableCheckboxSelector)) { this.slickGrid.setSelectedRows([]); @@ -1239,7 +1240,7 @@ export class SlickVanillaGridBundle { this.paginationService.totalItems = this.totalItems; this.paginationService.init(this.slickGrid, paginationOptions, this.backendServiceApi); this.subscriptions.push( - this._eventPubSubService.subscribe('onPaginationChanged', paginationChanges => this.paginationChanged(paginationChanges)), + this._eventPubSubService.subscribe('onPaginationChanged', paginationChanges => this.paginationChanged(paginationChanges)), this._eventPubSubService.subscribe<{ visible: boolean; }>('onPaginationVisibilityChanged', visibility => { this.showPagination = visibility?.visible ?? false; if (this.gridOptions?.backendServiceApi) { @@ -1263,11 +1264,12 @@ export class SlickVanillaGridBundle { */ protected renderPagination(showPagination = true): void { if (this.slickGrid && this._gridOptions?.enablePagination && !this._isPaginationInitialized && showPagination) { - this.slickPagination = new SlickPaginationComponent(this.slickGrid, this.paginationService, this._eventPubSubService, this.translaterService); - this.slickPagination.render(this._gridParentContainerElm); + const PaginationClass = this.gridOptions.customPaginationComponent ?? SlickPaginationComponent; + this.paginationComponent = new PaginationClass(this.slickGrid, this.paginationService, this._eventPubSubService, this.translaterService); + this.paginationComponent!.render(this._gridParentContainerElm); this._isPaginationInitialized = true; } else if (!showPagination) { - this.slickPagination?.dispose(); + this.paginationComponent?.dispose(); this._isPaginationInitialized = false; } } diff --git a/test/cypress/e2e/example30.cy.ts b/test/cypress/e2e/example30.cy.ts new file mode 100644 index 000000000..097e5c2d3 --- /dev/null +++ b/test/cypress/e2e/example30.cy.ts @@ -0,0 +1,77 @@ +describe('Example 30 - Custom Pagination', () => { + const GRID_ROW_HEIGHT = 40; + const titles = ['Title', 'Description', '% Complete', 'Start', 'Finish', 'Duration', 'Completed']; + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/example30`); + cy.get('h3').should('contain', 'Example 30 - Custom Pagination'); + }); + + it('should have exact Column Titles in the grid', () => { + cy.get('.grid30') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + + it('should expect first row to be Task 0', () => { + cy.get('#pager.top').should('exist'); + cy.get('.item-from').should('contain', 1); + cy.get('.item-to').should('contain', 50); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 0'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 1'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 2'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 3'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 4'); + }); + + it('should click on next page and expect top row to be Task 50', () => { + cy.get('.page-item.seek-next').click(); + + cy.get('.item-from').should('contain', 51); + cy.get('.item-to').should('contain', 100); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 50'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 51'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 52'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 53'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 54'); + }); + + it('should click on goto last page and expect top row to be Task 50', () => { + cy.get('.page-item.seek-end').click(); + + cy.get('.item-from').should('contain', 4951); + cy.get('.item-to').should('contain', 5000); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 4950'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 4951'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 4952'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 4953'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 4954'); + }); + + it('should change page size and expect pagination to be updated', () => { + cy.get('[data-test="page-size-input"]').type('{backspace}{backspace}75'); + cy.get('.item-from').should('contain', 1); + cy.get('.item-to').should('contain', 75); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 0'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 1'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 2'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 3'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 4'); + }); + + it('should toggle pagination position to bottom', () => { + cy.get('[data-text="toggle-pagination-btn"]').click(); + cy.get('#pager.bottom').should('exist'); + + cy.get('.page-item.seek-next').click(); + + cy.get('.item-from').should('contain', 76); + cy.get('.item-to').should('contain', 150); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 75'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 76'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 77'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 78'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 79'); + }); +});