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 all 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
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