Skip to content

Commit

Permalink
feat(graphql): add optional cursor pagination to GraphQL backend serv…
Browse files Browse the repository at this point in the history
…ice (#1153)

* feat: GraphQL add cursor pagination
---------

Co-authored-by: Ronald Van Ryswyk <ronald.van.ryswyk@positivecircularity.com>
  • Loading branch information
Harsgalt86 and Ronald Van Ryswyk authored Oct 31, 2023
1 parent bd132c5 commit 29579b2
Show file tree
Hide file tree
Showing 14 changed files with 706 additions and 231 deletions.
15 changes: 14 additions & 1 deletion examples/vite-demo-vanilla-bundle/src/examples/example10.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@ <h6 class="title is-6 italic">
<span style="font-style: italic" data-test="selected-locale" innerhtml.bind="selectedLanguageFile">
</span>
</span>

<span style="margin-left: 10px">
<label>Pagination strategy: </label>
<span data-test="radioStrategy">
<label class="radio-inline control-label" for="offset">
<input type="radio" name="inlineRadioOptions" data-test="offset" id="radioOffset" checked
onclick.delegate="setIsWithCursor(false)"> Offset
</label>
<label class="radio-inline control-label" for="radioCursor">
<input type="radio" name="inlineRadioOptions" data-test="cursor" id="radioCursor"
onclick.delegate="setIsWithCursor(true)"> Cursor
</label>
</span>
</span>
</div>

</div>
Expand All @@ -68,7 +82,6 @@ <h6 class="title is-6 italic">
</div>
</div>
</div>
</div>

<hr />

Expand Down
60 changes: 56 additions & 4 deletions examples/vite-demo-vanilla-bundle/src/examples/example10.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
BindingEventService,
Column,
CursorPageInfo,
FieldType,
Filters,
Formatters,
Expand Down Expand Up @@ -173,7 +174,7 @@ export default class Example10 {
{ columnId: 'name', direction: 'asc' },
{ columnId: 'company', direction: SortDirection.DESC }
],
pagination: { pageNumber: 2, pageSize: 20 }
pagination: { pageNumber: this.isWithCursor ? 1 : 2, pageSize: 20 } // if cursor based, start at page 1
},
backendServiceApi: {
service: new GraphqlService(),
Expand All @@ -184,6 +185,7 @@ export default class Example10 {
field: 'userId',
value: 123
}],
isWithCursor: this.isWithCursor, // sets pagination strategy, if true requires a call to setPageInfo() when graphql call returns
// when dealing with complex objects, we want to keep our field name with double quotes
// example with gender: query { users (orderBy:[{field:"gender",direction:ASC}]) {}
keepArgumentFieldDoubleQuotes: true
Expand Down Expand Up @@ -219,6 +221,35 @@ export default class Example10 {
* @return Promise<GraphqlPaginatedResult>
*/
getCustomerApiCall(_query: string): Promise<GraphqlPaginatedResult> {
let pageInfo: CursorPageInfo;
if (this.sgb) {
const { paginationService } = this.sgb;
// there seems to a timing issue where when you click "cursor" it requests the data before the pagination-service is initialized...
const pageNumber = (paginationService as any)._initialized ? paginationService.getCurrentPageNumber() : 1;
// In the real world, each node item would be A,B,C...AA,AB,AC, etc and so each page would actually be something like A-T, T-AN
// but for this mock data it's easier to represent each page as
// Page1: A-B
// Page2: B-C
// Page3: C-D
// Page4: D-E
// Page5: E-F
const startCursor = String.fromCharCode('A'.charCodeAt(0) + pageNumber - 1);
const endCursor = String.fromCharCode(startCursor.charCodeAt(0) + 1);
pageInfo = {
hasPreviousPage: paginationService.dataFrom === 0,
hasNextPage: paginationService.dataTo === 100,
startCursor,
endCursor
};
} else {
pageInfo = {
hasPreviousPage: false,
hasNextPage: true,
startCursor: 'A',
endCursor: 'B'
};
}

// in your case, you will call your WebAPI function (wich needs to return a Promise)
// for the demo purpose, we will call a mock WebAPI function
const mockedResult = {
Expand All @@ -227,14 +258,21 @@ export default class Example10 {
data: {
[GRAPHQL_QUERY_DATASET_NAME]: {
nodes: [],
totalCount: 100
}
}
totalCount: 100,
pageInfo
},
},
};

return new Promise<GraphqlPaginatedResult>(resolve => {
setTimeout(() => {
this.graphqlQuery = this.gridOptions.backendServiceApi!.service.buildQuery();
if (this.isWithCursor) {
// When using cursor pagination, the pagination service needs to updated with the PageInfo data from the latest request
// This might be done automatically if using a framework specific slickgrid library
// Note because of this timeout, this may cause race conditions with rapid clicks!
this.sgb?.paginationService.setCursorPageInfo((mockedResult.data[GRAPHQL_QUERY_DATASET_NAME].pageInfo));
}
resolve(mockedResult);
}, 150);
});
Expand Down Expand Up @@ -279,6 +317,20 @@ export default class Example10 {
]);
}

setIsWithCursor(newValue: boolean) {
this.isWithCursor = newValue;

// recreate grid and initiialisations
const parent = document.querySelector(`.grid10`)?.parentElement;
this.dispose();
if (parent) {
const newGrid10El = document.createElement('div');
newGrid10El.classList.add('grid10');
parent.appendChild(newGrid10El);
this.attached();
}
}

async switchLanguage() {
const nextLanguage = (this.selectedLanguage === 'en') ? 'fr' : 'en';
await this.translateService.use(nextLanguage);
Expand Down
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
Loading

0 comments on commit 29579b2

Please sign in to comment.