Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(graphql): add optional cursor pagination to GraphQL backend service #1153

Merged
merged 37 commits into from
Oct 31, 2023
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
9d638ae
Cursor pagination works when changing page sizes and moving forwards,…
Oct 19, 2023
fff039d
Backwards navigation working
Oct 19, 2023
0b33704
Navigate to first page and last page using cursor pagination
Oct 19, 2023
4b46567
updatePaginationOverloading... might refactor as optional argument at…
Oct 20, 2023
e6c9fc7
graphql forward pagination
Oct 20, 2023
01ceb2b
Fix page backwards. Note for pagination with numbers to work correctl…
Oct 20, 2023
13044ce
got first/last page. forward/backwards navigate working
Oct 24, 2023
a024918
pagination component is a normal text element for current page when i…
Oct 24, 2023
fbee9d0
unit tests passed
Oct 24, 2023
59678d2
slickpagination integration tests combined for cursor and non-cursor …
Oct 26, 2023
84076d6
documentation on why the mockFullPagination reset
Oct 26, 2023
637f9a5
pagination service unit tests
Oct 26, 2023
9966cfa
graphql service tests for cursor pagination
Oct 26, 2023
4128362
Merge branch 'ghiscoding:master' into graphql-cursor-pagination
Harsgalt86 Oct 26, 2023
b1f0444
cleanup
Oct 26, 2023
6817ad1
Documentation
Oct 26, 2023
eb5bb16
Merge branch 'graphql-cursor-pagination' of https://github.com/Harsga…
Oct 26, 2023
91ab89e
grammer
Oct 26, 2023
de4cc8b
grammer
Oct 26, 2023
bd17eb6
Move _pageInfo to the other instance properties and change to protected
Oct 26, 2023
befb131
Merge branch 'ghiscoding:master' into graphql-cursor-pagination
Harsgalt86 Oct 26, 2023
6764b87
odata shouldn't know about cursors if it doesn't support them (keep a…
Oct 27, 2023
bd6fc36
Merge branch 'graphql-cursor-pagination' of https://github.com/Harsga…
Oct 27, 2023
10d0237
add to test coverage
Oct 27, 2023
801b78a
improve coverage
Oct 27, 2023
e18e097
tests for refactored code which created new branches
Oct 27, 2023
eab8e3c
remove console statement
Oct 27, 2023
1089284
Restore accidentally deleted test (slightly modified to handle cursor…
Oct 27, 2023
f678371
Rename PageInfo to CursorPageInfo
Oct 29, 2023
1696ab6
rename cursorBased to isCursorBased
Oct 29, 2023
37f871b
cleanup nested conditions to be easier to read
Oct 30, 2023
f3962b6
Specific es-lint rules to disable
Oct 30, 2023
d1d01e6
change to "import type"
Oct 30, 2023
252f5c3
E2E tests for cursor pagination
Oct 31, 2023
a012424
Remove unused property
Oct 31, 2023
c520167
Merge branch 'ghiscoding:master' into graphql-cursor-pagination
Harsgalt86 Oct 31, 2023
3805d79
fix default props when changing filter with cursor
Oct 31, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions packages/common/src/interfaces/backendService.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
MultiColumnSort,
Pagination,
PaginationChangedArgs,
PaginationCursorChangedArgs,
SingleColumnSort,
} from './index';
import { SlickGrid } from './slickGrid.interface';
Expand Down Expand Up @@ -50,8 +51,8 @@ export interface BackendService {
/** Update the Filters options with a set of new options */
updateFilters?: (columnFilters: ColumnFilters | CurrentFilter[], isUpdatedByPresetOrDynamically: boolean) => void;

/** Update the Pagination component with it's new page number and size */
updatePagination?: (newPage: number, pageSize: number) => void;
/** Update the Pagination component with it's new page number and size. If using cursor based pagination, a CursorPageInfo object needs to be supplied */
updatePagination?: (newPage: number, pageSize: number, cursorArgs?: PaginationCursorChangedArgs) => void;

/** Update the Sorters options with a set of new options */
updateSorters?: (sortColumns?: Array<SingleColumnSort>, presetSorters?: CurrentSorter[]) => void;
Expand All @@ -67,7 +68,7 @@ export interface BackendService {
processOnFilterChanged: (event: Event | KeyboardEvent | undefined, args: FilterChangedArgs) => string;

/** Execute when the pagination changed */
processOnPaginationChanged: (event: Event | undefined, args: PaginationChangedArgs) => string;
processOnPaginationChanged: (event: Event | undefined, args: PaginationChangedArgs | (PaginationCursorChangedArgs & PaginationChangedArgs)) => string;

/** Execute when any of the sorters changed */
processOnSortChanged: (event: Event | undefined, args: SingleColumnSort | MultiColumnSort) => string;
Expand Down
13 changes: 13 additions & 0 deletions packages/common/src/interfaces/cursorPageInfo.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export interface CursorPageInfo {
/** Do we have a next page from current cursor position? */
hasNextPage: boolean;

/** Do we have a previous page from current cursor position? */
hasPreviousPage: boolean;

/** What is the last cursor? */
endCursor: string;

/** What is the first cursor? */
startCursor: string;
}
2 changes: 2 additions & 0 deletions packages/common/src/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export * from './currentPagination.interface';
export * from './currentPinning.interface';
export * from './currentRowSelection.interface';
export * from './currentSorter.interface';
export * from './cursorPageInfo.interface';
export * from './customFooterOption.interface';
export * from './customTooltipOption.interface';
export * from './dataViewOption.interface';
Expand Down Expand Up @@ -120,6 +121,7 @@ export * from './onValidationErrorResult.interface';
export * from './operatorDetail.interface';
export * from './pagination.interface';
export * from './paginationChangedArgs.interface';
export * from './paginationCursorChangedArgs.interface';
export * from './pagingInfo.interface';
export * from './resizeByContentOption.interface';
export * from './resizer.interface';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { PaginationChangedArgs } from './paginationChangedArgs.interface';

export interface PaginationCursorChangedArgs extends PaginationChangedArgs {
/** Start our page After cursor X */
after?: string;

/** Start our page Before cursor X */
before?: string;

/** Get first X number of objects */
first?: number;

/** Get last X number of objects */
last?: number;
}
118 changes: 114 additions & 4 deletions packages/common/src/services/__tests__/pagination.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { of, throwError } from 'rxjs';

import { PaginationService } from './../pagination.service';
import { SharedService } from '../shared.service';
import { Column, SlickDataView, GridOption, SlickGrid, SlickNamespace, BackendServiceApi, Pagination } from '../../interfaces/index';
import { Column, CursorPageInfo, SlickDataView, GridOption, SlickGrid, SlickNamespace, BackendServiceApi, Pagination } from '../../interfaces/index';
import { BackendUtilityService } from '../backendUtility.service';
import { RxJsResourceStub } from '../../../../../test/rxjsResourceStub';

Expand Down Expand Up @@ -61,6 +61,23 @@ const mockGridOption = {
}
} as GridOption;

const mockGridOptionWithCursorPaginationBackend = {
...mockGridOption,
backendServiceApi: {
service: mockBackendService,
process: jest.fn(),
options: {
columnDefinitions: [{ id: 'name', field: 'name' }] as Column[],
datasetName: 'user',
isWithCursor: true,
}
},
} as GridOption;

const mockCursorPageInfo = {
startCursor: "b", endCursor: "c", hasNextPage: true, hasPreviousPage: true, // b-c simulates page 2
} as CursorPageInfo;

const gridStub = {
autosizeColumns: jest.fn(),
getColumnIndex: jest.fn(),
Expand Down Expand Up @@ -206,6 +223,27 @@ describe('PaginationService', () => {
expect(service.getCurrentPageNumber()).toBe(1);
expect(spy).toHaveBeenCalledWith(1, undefined);
});

it('should expect current page to be 1 and "processOnPageChanged" method to be called with cursorArgs when backend service is cursor based', () => {
const spy = jest.spyOn(service, 'processOnPageChanged');
service.init(gridStub, mockGridOptionWithCursorPaginationBackend.pagination as Pagination, mockGridOptionWithCursorPaginationBackend.backendServiceApi);
service.setCursorPageInfo(mockCursorPageInfo);
service.goToFirstPage();

expect(service.dataFrom).toBe(1);
expect(service.dataTo).toBe(25);
expect(service.getCurrentPageNumber()).toBe(1);
expect(spy).toHaveBeenCalledWith(1, undefined, { first: 25, newPage: 1, pageSize: 25 });
});

it('should expect current page to be 1 and "processOnPageChanged" method NOT to be called', () => {
const spy = jest.spyOn(service, 'processOnPageChanged');
service.init(gridStub, mockGridOption.pagination as Pagination, mockGridOption.backendServiceApi);
service.goToFirstPage(null, false);

expect(service.getCurrentPageNumber()).toBe(1);
expect(spy).not.toHaveBeenCalled();
});
});

describe('goToLastPage method', () => {
Expand All @@ -220,6 +258,29 @@ describe('PaginationService', () => {
expect(service.getCurrentPageNumber()).toBe(4);
expect(spy).toHaveBeenCalledWith(4, undefined);
});

it('should call "goToLastPage" method and expect current page to be last page and "processOnPageChanged" method to be called with cursorArgs when backend service is cursor based', () => {
const spy = jest.spyOn(service, 'processOnPageChanged');

service.init(gridStub, mockGridOptionWithCursorPaginationBackend.pagination as Pagination, mockGridOptionWithCursorPaginationBackend.backendServiceApi);
service.setCursorPageInfo(mockCursorPageInfo);
service.goToLastPage();

expect(service.dataFrom).toBe(76);
expect(service.dataTo).toBe(85);
expect(service.getCurrentPageNumber()).toBe(4);
expect(spy).toHaveBeenCalledWith(4, undefined, { last: 25, newPage: 4, pageSize: 25 });
});

it('should call "goToLastPage" method and expect current page to be last page and "processOnPageChanged" method NOT to be called', () => {
const spy = jest.spyOn(service, 'processOnPageChanged');

service.init(gridStub, mockGridOption.pagination as Pagination, mockGridOption.backendServiceApi);
service.goToLastPage(null, false);

expect(service.getCurrentPageNumber()).toBe(4);
expect(spy).not.toHaveBeenCalledWith();
});
});

describe('goToNextPage method', () => {
Expand All @@ -235,16 +296,27 @@ describe('PaginationService', () => {
expect(spy).toHaveBeenCalledWith(3, undefined);
});

it('should expect page to increment by 1 and "processOnPageChanged" method to be called', () => {
it('should expect page to increment by 1 and "processOnPageChanged" method to be called with cursorArgs when backend service is cursor based', () => {
const spy = jest.spyOn(service, 'processOnPageChanged');

service.init(gridStub, mockGridOption.pagination as Pagination, mockGridOption.backendServiceApi);
service.init(gridStub, mockGridOptionWithCursorPaginationBackend.pagination as Pagination, mockGridOptionWithCursorPaginationBackend.backendServiceApi);
service.setCursorPageInfo(mockCursorPageInfo);
service.goToNextPage();

expect(service.dataFrom).toBe(51);
expect(service.dataTo).toBe(75);
expect(service.getCurrentPageNumber()).toBe(3);
expect(spy).toHaveBeenCalledWith(3, undefined);
expect(spy).toHaveBeenCalledWith(3, undefined, {first: 25, after: "c", newPage: 3, pageSize: 25 });
});

it('should expect page to increment by 1 and "processOnPageChanged" method NOT to be called', () => {
const spy = jest.spyOn(service, 'processOnPageChanged');

service.init(gridStub, mockGridOption.pagination as Pagination, mockGridOption.backendServiceApi);
service.goToNextPage(null, false);

expect(service.getCurrentPageNumber()).toBe(3);
expect(spy).not.toHaveBeenCalled();
});

it('should not expect "processOnPageChanged" method to be called when we are already on last page', () => {
Expand Down Expand Up @@ -274,6 +346,29 @@ describe('PaginationService', () => {
expect(spy).toHaveBeenCalledWith(1, undefined);
});

it('should expect page to decrement by 1 and "processOnPageChanged" method to be called with cursorArgs when backend service is cursor based', () => {
const spy = jest.spyOn(service, 'processOnPageChanged');

service.init(gridStub, mockGridOptionWithCursorPaginationBackend.pagination as Pagination, mockGridOptionWithCursorPaginationBackend.backendServiceApi);
service.setCursorPageInfo(mockCursorPageInfo);
service.goToPreviousPage();

expect(service.dataFrom).toBe(1);
expect(service.dataTo).toBe(25);
expect(service.getCurrentPageNumber()).toBe(1);
expect(spy).toHaveBeenCalledWith(1, undefined, {last: 25, before: "b", newPage: 1, pageSize: 25 });
});

it('should expect page to decrement by 1 and "processOnPageChanged" method NOT to be called', () => {
const spy = jest.spyOn(service, 'processOnPageChanged');

service.init(gridStub, mockGridOptionWithCursorPaginationBackend.pagination as Pagination, mockGridOptionWithCursorPaginationBackend.backendServiceApi);
service.goToPreviousPage(null, false);

expect(service.getCurrentPageNumber()).toBe(1);
expect(spy).not.toHaveBeenCalled()
});

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;
Expand Down Expand Up @@ -338,6 +433,21 @@ describe('PaginationService', () => {
expect(spy).not.toHaveBeenCalled();
expect(output).toBeFalsy();
});

it('should not expect "processOnPageChanged" method to be called when backend service is cursor based', async () => {
const spy = jest.spyOn(service, 'processOnPageChanged');
service.setCursorPageInfo(mockCursorPageInfo);
service.init(gridStub, mockGridOptionWithCursorPaginationBackend.pagination as Pagination, mockGridOptionWithCursorPaginationBackend.backendServiceApi);

const output = await service.goToPageNumber(3);

// stay on current page
expect(service.dataFrom).toBe(26);
expect(service.dataTo).toBe(50);
expect(service.getCurrentPageNumber()).toBe(2);
expect(spy).not.toHaveBeenCalled();
expect(output).toBeFalsy();
});
});

describe('processOnPageChanged method', () => {
Expand Down
71 changes: 65 additions & 6 deletions packages/common/src/services/pagination.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { dequal } from 'dequal/lite';
import type {
BackendServiceApi,
CurrentPagination,
CursorPageInfo,
Pagination,
PaginationCursorChangedArgs,
ServicePagination,
SlickDataView,
SlickGrid,
Expand Down Expand Up @@ -32,6 +34,7 @@ export class PaginationService {
protected _paginationOptions!: Pagination;
protected _previousPagination?: Pagination;
protected _subscriptions: EventSubscription[] = [];
protected _cursorPageInfo?: CursorPageInfo;

/** SlickGrid Grid object */
grid!: SlickGrid;
Expand Down Expand Up @@ -86,6 +89,27 @@ export class PaginationService {
}
}

/**
* https://dev.to/jackmarchant/offset-and-cursor-pagination-explained-b89
* Cursor based pagination does not allow navigation to a page in the middle of a set of pages (eg: LinkedList vs Vector).
* Further, Pagination with page numbers only makes sense in non-relay style pagination
* Relay style pagination is better suited to infinite scrolling
*
* eg
* relay pagination - Infinte scrolling appending data
* page1: {startCursor: A, endCursor: B }
* page2: {startCursor: A, endCursor: C }
* page3: {startCursor: A, endCursor: D }
*
* non-relay pagination - Getting page chunks
* page1: {startCursor: A, endCursor: B }
* page2: {startCursor: B, endCursor: C }
* page3: {startCursor: C, endCursor: D }
*/
get isCursorBased(): boolean {
return !!this._backendServiceApi?.options.isWithCursor;
}

addRxJsResource(rxjs: RxJsFacade) {
this.rxjs = rxjs;
}
Expand Down Expand Up @@ -184,23 +208,44 @@ export class PaginationService {

goToFirstPage(event?: any, triggerChangeEvent = true): Promise<ServicePagination> {
this._pageNumber = 1;
return triggerChangeEvent ? this.processOnPageChanged(this._pageNumber, event) : Promise.resolve(this.getFullPagination());
if (triggerChangeEvent) {
return this.isCursorBased && this._cursorPageInfo
? this.processOnPageChanged(this._pageNumber, event, { newPage: this._pageNumber, pageSize: this._itemsPerPage, first: this._itemsPerPage })
: this.processOnPageChanged(this._pageNumber, event);
}
return Promise.resolve(this.getFullPagination());
}

goToLastPage(event?: any, triggerChangeEvent = true): Promise<ServicePagination> {
this._pageNumber = this._pageCount || 1;
return triggerChangeEvent ? this.processOnPageChanged(this._pageNumber || 1, event) : Promise.resolve(this.getFullPagination());
if (triggerChangeEvent) {
return this.isCursorBased && this._cursorPageInfo
? this.processOnPageChanged(this._pageNumber, event, { newPage: this._pageNumber, pageSize: this._itemsPerPage, last: this._itemsPerPage })
: this.processOnPageChanged(this._pageNumber, event);
}
return Promise.resolve(this.getFullPagination());
}

goToNextPage(event?: any, triggerChangeEvent = true): Promise<boolean | ServicePagination> {
if (this._pageNumber < this._pageCount) {
this._pageNumber++;
return triggerChangeEvent ? this.processOnPageChanged(this._pageNumber, event) : Promise.resolve(this.getFullPagination());
if (triggerChangeEvent) {
return this.isCursorBased && this._cursorPageInfo
? this.processOnPageChanged(this._pageNumber, event, { newPage: this._pageNumber, pageSize: this._itemsPerPage, first: this._itemsPerPage, after: this._cursorPageInfo.endCursor })
: this.processOnPageChanged(this._pageNumber, event);
} else {
return Promise.resolve(this.getFullPagination());
}
}
return Promise.resolve(false);
}

goToPageNumber(pageNumber: number, event?: any, triggerChangeEvent = true): Promise<boolean | ServicePagination> {
if (this.isCursorBased) {
console.assert(true, 'Cursor based navigation cannot navigate to arbitrary page');
return Promise.resolve(false);
}

const previousPageNumber = this._pageNumber;

if (pageNumber < 1) {
Expand All @@ -220,7 +265,13 @@ export class PaginationService {
goToPreviousPage(event?: any, triggerChangeEvent = true): Promise<boolean | ServicePagination> {
if (this._pageNumber > 1) {
this._pageNumber--;
return triggerChangeEvent ? this.processOnPageChanged(this._pageNumber, event) : Promise.resolve(this.getFullPagination());
if (triggerChangeEvent) {
return this.isCursorBased && this._cursorPageInfo
? this.processOnPageChanged(this._pageNumber, event, { newPage: this._pageNumber, pageSize: this._itemsPerPage, last: this._itemsPerPage, before: this._cursorPageInfo.startCursor })
: this.processOnPageChanged(this._pageNumber, event);
} else {
return Promise.resolve(this.getFullPagination());
}
}
return Promise.resolve(false);
}
Expand Down Expand Up @@ -321,7 +372,9 @@ export class PaginationService {
}
}

processOnPageChanged(pageNumber: number, event?: Event | undefined): Promise<ServicePagination> {
processOnPageChanged(pageNumber: number, event?: Event | undefined, cursorArgs?: PaginationCursorChangedArgs): Promise<ServicePagination> {
console.assert(!this.isCursorBased || cursorArgs, 'Configured for cursor based pagination - cursorArgs expected');

if (this.pubSubService.publish('onBeforePaginationChange', this.getFullPagination()) === false) {
this.resetToPreviousPagination();
return Promise.resolve(this.getFullPagination());
Expand All @@ -347,7 +400,9 @@ export class PaginationService {
}

if (this._backendServiceApi?.process) {
const query = this._backendServiceApi.service.processOnPaginationChanged(event, { newPage: pageNumber, pageSize: itemsPerPage });
const query = this.isCursorBased && cursorArgs
? this._backendServiceApi.service.processOnPaginationChanged(event, cursorArgs)
: this._backendServiceApi.service.processOnPaginationChanged(event, { newPage: pageNumber, pageSize: itemsPerPage });

// the processes can be Promises
const process = this._backendServiceApi.process(query);
Expand Down Expand Up @@ -437,6 +492,10 @@ export class PaginationService {
}
}

setCursorPageInfo(pageInfo: CursorPageInfo) {
this._cursorPageInfo = pageInfo;
}

updateTotalItems(totalItems: number, triggerChangedEvent = false) {
this._totalItems = totalItems;
if (this._paginationOptions) {
Expand Down
Loading