Skip to content

Commit

Permalink
Merge pull request #1299 from ghiscoding/feat/cursor-pagination
Browse files Browse the repository at this point in the history
feat: update GraphQL demo with cursor pagination
  • Loading branch information
ghiscoding authored Nov 2, 2023
2 parents 34bcf7f + e5fe959 commit 5b5fbed
Show file tree
Hide file tree
Showing 5 changed files with 285 additions and 81 deletions.
24 changes: 12 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,13 @@
},
"dependencies": {
"@ngx-translate/core": ">=15.0.0 <16.0.0",
"@slickgrid-universal/common": "~3.4.0",
"@slickgrid-universal/custom-footer-component": "~3.4.0",
"@slickgrid-universal/empty-warning-component": "~3.4.0",
"@slickgrid-universal/common": "~3.4.2",
"@slickgrid-universal/custom-footer-component": "~3.4.2",
"@slickgrid-universal/empty-warning-component": "~3.4.2",
"@slickgrid-universal/event-pub-sub": "~3.4.0",
"@slickgrid-universal/pagination-component": "~3.4.0",
"@slickgrid-universal/row-detail-view-plugin": "~3.4.0",
"@slickgrid-universal/rxjs-observable": "~3.4.0",
"@slickgrid-universal/pagination-component": "~3.4.2",
"@slickgrid-universal/row-detail-view-plugin": "~3.4.2",
"@slickgrid-universal/rxjs-observable": "~3.4.2",
"dequal": "^2.0.3",
"dompurify": "^3.0.6",
"rxjs": "^7.8.1",
Expand Down Expand Up @@ -85,12 +85,12 @@
"@ng-select/ng-select": "^11.2.0",
"@ngx-translate/http-loader": "^8.0.0",
"@release-it/conventional-changelog": "^7.0.2",
"@slickgrid-universal/composite-editor-component": "~3.4.0",
"@slickgrid-universal/custom-tooltip-plugin": "~3.4.0",
"@slickgrid-universal/excel-export": "~3.4.0",
"@slickgrid-universal/graphql": "~3.4.0",
"@slickgrid-universal/odata": "~3.4.0",
"@slickgrid-universal/text-export": "~3.4.0",
"@slickgrid-universal/composite-editor-component": "~3.4.2",
"@slickgrid-universal/custom-tooltip-plugin": "~3.4.2",
"@slickgrid-universal/excel-export": "~3.4.2",
"@slickgrid-universal/graphql": "~3.4.2",
"@slickgrid-universal/odata": "~3.4.2",
"@slickgrid-universal/text-export": "~3.4.2",
"@types/dompurify": "^3.0.4",
"@types/flatpickr": "^3.1.2",
"@types/fnando__sparkline": "^0.3.6",
Expand Down
15 changes: 15 additions & 0 deletions src/app/examples/grid-graphql.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ <h2>
(click)="setSortingDynamically()">
Set Sorting Dynamically
</button>
<button class="btn btn-outline-secondary btn-sm" data-test="reset-presets"
(click)="resetToOriginalPresets()">
Reset Original Presets
</button>
</div>
</div>
<div class="row mt-1">
Expand All @@ -48,6 +52,17 @@ <h2>
{{selectedLanguage + '.json'}}
</span>
</div>

<span style="margin-left: 10px">
<label class="radio-inline control-label" for="radioOffset">
<input type="radio" name="inlineRadioOptions" data-test="offset" id="radioOffset" checked [value]="false"
(change)="setIsWithCursor(false)"> Offset
</label>
<label class="radio-inline control-label" for="radioCursor">
<input type="radio" name="inlineRadioOptions" data-test="cursor" id="radioCursor" [value]="true"
(change)="setIsWithCursor(true)"> Cursor
</label>
</span>
</div>
<br />
<div *ngIf="metrics" style="margin: 10px 0px">
Expand Down
86 changes: 81 additions & 5 deletions src/app/examples/grid-graphql.component.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { GraphqlService, GraphqlPaginatedResult, GraphqlServiceApi, } from '@slickgrid-universal/graphql';
import { GraphqlService, GraphqlPaginatedResult, GraphqlServiceApi, GraphqlServiceOption, } from '@slickgrid-universal/graphql';
import { Component, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import {
AngularGridInstance,
Column,
CursorPageInfo,
FieldType,
Filters,
Formatters,
Expand Down Expand Up @@ -48,10 +49,10 @@ export class GridGraphqlComponent implements OnInit, OnDestroy {
dataset = [];
metrics!: Metrics;

isWithCursor = false;
graphqlQuery = '';
processing = true;
status = { text: 'processing...', class: 'alert alert-danger' };
isWithCursor = false;
selectedLanguage: string;

constructor(private readonly cd: ChangeDetectorRef, private translate: TranslateService) {
Expand Down Expand Up @@ -180,7 +181,7 @@ export class GridGraphqlComponent implements OnInit, OnDestroy {
{ columnId: 'name', direction: 'asc' },
{ columnId: 'company', direction: SortDirection.DESC }
],
pagination: { pageNumber: 2, pageSize: defaultPageSize }
pagination: { pageNumber: this.isWithCursor ? 1 : 2, pageSize: 20 } // if cursor based, start at page 1
},
backendServiceApi: {
service: new GraphqlService(),
Expand All @@ -191,6 +192,7 @@ export class GridGraphqlComponent implements OnInit, OnDestroy {
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 @@ -224,7 +226,36 @@ export class GridGraphqlComponent implements OnInit, OnDestroy {
* @param query
* @return Promise<GraphqlPaginatedResult> | Observable<GraphqlResult>
*/
getCustomerApiCall(query: string): Promise<GraphqlPaginatedResult> {
getCustomerApiCall(_query: string): Promise<GraphqlPaginatedResult> {
let pageInfo: CursorPageInfo;
if (this.angularGrid?.paginationService) {
const { paginationService } = this.angularGrid;
// 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 @@ -233,14 +264,21 @@ export class GridGraphqlComponent implements OnInit, OnDestroy {
data: {
[GRAPHQL_QUERY_DATASET_NAME]: {
nodes: [],
totalCount: 100
totalCount: 100,
pageInfo
}
}
};

return new Promise(resolve => {
setTimeout(() => {
this.graphqlQuery = this.angularGrid.backendService!.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.angularGrid?.paginationService?.setCursorPageInfo((mockedResult.data[GRAPHQL_QUERY_DATASET_NAME].pageInfo));
}
resolve(mockedResult);
}, 100);
});
Expand Down Expand Up @@ -293,6 +331,44 @@ export class GridGraphqlComponent implements OnInit, OnDestroy {
]);
}

resetToOriginalPresets() {
const presetLowestDay = moment().add(-2, 'days').format('YYYY-MM-DD');
const presetHighestDay = moment().add(20, 'days').format('YYYY-MM-DD');

this.angularGrid.filterService.updateFilters([
// you can use OperatorType or type them as string, e.g.: operator: 'EQ'
{ columnId: 'gender', searchTerms: ['male'], operator: OperatorType.equal },
{ columnId: 'name', searchTerms: ['John Doe'], operator: OperatorType.contains },
{ columnId: 'company', searchTerms: ['xyz'], operator: 'IN' },

// use a date range with 2 searchTerms values
{ columnId: 'finish', searchTerms: [presetLowestDay, presetHighestDay], operator: OperatorType.rangeInclusive },
]);
this.angularGrid.sortService.updateSorting([
// direction can written as 'asc' (uppercase or lowercase) and/or use the SortDirection type
{ columnId: 'name', direction: 'asc' },
{ columnId: 'company', direction: SortDirection.DESC }
]);
setTimeout(() => {
this.angularGrid.paginationService?.changeItemPerPage(20);
this.angularGrid.paginationService?.goToPageNumber(2);
});
}

setIsWithCursor(isWithCursor: boolean) {
this.isWithCursor = isWithCursor;
this.resetOptions({ isWithCursor: this.isWithCursor });
return true;
}

private resetOptions(options: Partial<GraphqlServiceOption>) {
const graphqlService = this.gridOptions.backendServiceApi!.service as GraphqlService;
this.angularGrid.paginationService!.setCursorBased(options.isWithCursor!);
this.angularGrid.paginationService?.goToFirstPage();
graphqlService.updateOptions(options);
this.gridOptions = { ...this.gridOptions };
}

switchLanguage() {
const nextLanguage = (this.selectedLanguage === 'en') ? 'fr' : 'en';
this.subscriptions.push(
Expand Down
113 changes: 113 additions & 0 deletions test/cypress/e2e/example06.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,4 +653,117 @@ describe('Example 6 - GraphQL Grid', { retries: 1 }, () => {
.should('contain.value', 'au'); // date range will contains (y to z) or in French (y au z)
});
});

describe('Cursor Pagination', () => {
it('should re-initialize grid for cursor pagination', () => {
cy.reload().wait(250);
// cy.get('[data-test="reset-presets"]').click();
cy.get('[data-test=cursor]').click();

// the page number input should be a label now
// cy.get('[data-test=page-number-label]').should('exist').should('have.text', '1');
cy.get('[data-test=page-number-input]')
.invoke('val')
.then(text => expect(text).to.eq('1'));
});

it('should change Pagination to the last page', () => {
// Go to first page (if not already there)
cy.get('[data-test=goto-first-page').click();

cy.get('.icon-seek-end').click();

// wait for the query to finish
cy.get('[data-test=status]').should('contain', 'finished');
cy.get('[data-test=graphql-query-result]')
.should(($span) => {
const text = removeWhitespaces($span.text()); // remove all white spaces
expect(text).to.eq(removeWhitespaces(`query{users(last:20,
orderBy:[{field:"name",direction:ASC},{field:"company",direction:DESC}],
filterBy:[
{field:"gender",operator:EQ,value:"male"},{field:"name",operator:Contains,value:"JohnDoe"},
{field:"company",operator:IN,value:"xyz"},{field:"finish",operator:GE,value:"${presetLowestDay}"},{field:"finish",operator:LE,value:"${presetHighestDay}"}
],locale:"en",userId:123){totalCount,nodes{id,name,gender,company,billing{address{street,zip}},finish},pageInfo{hasNextPage,hasPreviousPage,endCursor,startCursor},edges{cursor}}}`));
});
});

it('should change Pagination to the first page', () => {
// Go to first page (if not already there)
cy.get('[data-test=goto-last-page').click();

cy.get('.icon-seek-first').click();

// wait for the query to finish
cy.get('[data-test=status]').should('contain', 'finished');
cy.get('[data-test=graphql-query-result]')
.should(($span) => {
const text = removeWhitespaces($span.text()); // remove all white spaces
expect(text).to.eq(removeWhitespaces(`query{users(first:20,
orderBy:[{field:"name",direction:ASC},{field:"company",direction:DESC}],
filterBy:[
{field:"gender",operator:EQ,value:"male"},{field:"name",operator:Contains,value:"JohnDoe"},
{field:"company",operator:IN,value:"xyz"},{field:"finish",operator:GE,value:"${presetLowestDay}"},{field:"finish",operator:LE,value:"${presetHighestDay}"}
],locale:"en",userId:123){totalCount,nodes{id,name,gender,company,billing{address{street,zip}},finish},pageInfo{hasNextPage,hasPreviousPage,endCursor,startCursor},edges{cursor}}}`));
});
});

it('should change Pagination to next page and all the way to the last', () => {
// Go to first page (if not already there)
cy.get('[data-test=goto-first-page').click();
cy.get('[data-test=status]').should('contain', 'finished');

// on page 1, click 4 times to get to page 5 (the last page)
cy.wrap([0, 1, 2, 3]).each((el, i) => {
cy.wait(200); // Avoid clicking too fast and hitting race conditions because of the setTimeout in the example page (this timeout should be greater than in the page)
cy.get('.icon-seek-next').click().then(() => {
// wait for the query to finish
cy.get('[data-test=status]').should('contain', 'finished');
cy.get('[data-test=graphql-query-result]')
.should(($span) => {
// First page is A-B
// first click is to get page after A-B
// => get first 20 after 'B'
const afterCursor = String.fromCharCode('B'.charCodeAt(0) + i);

const text = removeWhitespaces($span.text()); // remove all white spaces
expect(text).to.eq(removeWhitespaces(`query{users(first:20,after:"${afterCursor}",
orderBy:[{field:"name",direction:ASC},{field:"company",direction:DESC}],
filterBy:[
{field:"gender",operator:EQ,value:"male"},{field:"name",operator:Contains,value:"JohnDoe"},
{field:"company",operator:IN,value:"xyz"},{field:"finish",operator:GE,value:"${presetLowestDay}"},{field:"finish",operator:LE,value:"${presetHighestDay}"}
],locale:"en",userId:123){totalCount,nodes{id,name,gender,company,billing{address{street,zip}},finish},pageInfo{hasNextPage,hasPreviousPage,endCursor,startCursor},edges{cursor}}}`));
});
});
});
});

it('should change Pagination from the last page all the way to the first', () => {
// Go to last page (if not already there)
cy.get('[data-test=goto-last-page').click();

// on page 5 (last page), click 4 times to go to page 1
cy.wrap([0, 1, 2, 3]).each((el, i) => {
cy.wait(200); // Avoid clicking too fast and hitting race conditions because of the setTimeout in the example page (this timeout should be greater than in the page)
cy.get('.icon-seek-prev').click().then(() => {
// wait for the query to finish
cy.get('[data-test=status]').should('contain', 'finished');
cy.get('[data-test=graphql-query-result]')
.should(($span) => {
// Last page is E-F
// first click is to get page before E-F
// => get last 20 before 'E'
const beforeCursor = String.fromCharCode('E'.charCodeAt(0) - i);

const text = removeWhitespaces($span.text()); // remove all white spaces
expect(text).to.eq(removeWhitespaces(`query{users(last:20,before:"${beforeCursor}",
orderBy:[{field:"name",direction:ASC},{field:"company",direction:DESC}],
filterBy:[
{field:"gender",operator:EQ,value:"male"},{field:"name",operator:Contains,value:"JohnDoe"},
{field:"company",operator:IN,value:"xyz"},{field:"finish",operator:GE,value:"${presetLowestDay}"},{field:"finish",operator:LE,value:"${presetHighestDay}"}
],locale:"en",userId:123){totalCount,nodes{id,name,gender,company,billing{address{street,zip}},finish},pageInfo{hasNextPage,hasPreviousPage,endCursor,startCursor},edges{cursor}}}`));
});
});
});
});
});
});
Loading

0 comments on commit 5b5fbed

Please sign in to comment.