Skip to content

Commit

Permalink
feat: Infinite Scroll for JSON data
Browse files Browse the repository at this point in the history
  • Loading branch information
ghiscoding committed Aug 7, 2024
1 parent 13e2697 commit 251a984
Show file tree
Hide file tree
Showing 6 changed files with 357 additions and 0 deletions.
2 changes: 2 additions & 0 deletions src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { GridGraphqlComponent } from './examples/grid-graphql.component';
import { GridGraphqlWithoutPaginationComponent } from './examples/grid-graphql-nopage.component';
import { GridGroupingComponent } from './examples/grid-grouping.component';
import { GridInfiniteGraphqlComponent } from './examples/grid-infinite-graphql.component';
import { GridInfiniteJsonComponent } from './examples/grid-infinite-json.component';
import { GridInfiniteOdataComponent } from './examples/grid-infinite-odata.component';
import { GridHeaderButtonComponent } from './examples/grid-headerbutton.component';
import { GridHeaderFooterComponent } from './examples/grid-header-footer.component';
Expand Down Expand Up @@ -70,6 +71,7 @@ const routes: Routes = [
{ path: 'grouping', component: GridGroupingComponent },
{ path: 'header-footer', component: GridHeaderFooterComponent },
{ path: 'infinite-graphql', component: GridInfiniteGraphqlComponent },
{ path: 'infinite-json', component: GridInfiniteJsonComponent },
{ path: 'infinite-odata', component: GridInfiniteOdataComponent },
{ path: 'localization', component: GridLocalizationComponent },
{ path: 'clientside', component: GridClientSideComponent },
Expand Down
5 changes: 5 additions & 0 deletions src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,11 @@
39- Infinite Scroll with GraphQL
</a>
</li>
<li class="nav-item">
<a class="nav-link" routerLinkActive="active" [routerLink]="['/infinite-json']">
40- Infinite Scroll from JSON data
</a>
</li>
</ul>
</section>

Expand Down
2 changes: 2 additions & 0 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { GridGroupingComponent } from './examples/grid-grouping.component';
import { GridHeaderButtonComponent } from './examples/grid-headerbutton.component';
import { GridHeaderMenuComponent } from './examples/grid-headermenu.component';
import { GridInfiniteGraphqlComponent } from './examples/grid-infinite-graphql.component';
import { GridInfiniteJsonComponent } from './examples/grid-infinite-json.component';
import { GridInfiniteOdataComponent } from './examples/grid-infinite-odata.component';
import { GridLocalizationComponent } from './examples/grid-localization.component';
import { GridMenuComponent } from './examples/grid-menu.component';
Expand Down Expand Up @@ -121,6 +122,7 @@ export function appInitializerFactory(translate: TranslateService, injector: Inj
GridHeaderFooterComponent,
GridHeaderMenuComponent,
GridInfiniteGraphqlComponent,
GridInfiniteJsonComponent,
GridInfiniteOdataComponent,
GridLocalizationComponent,
GridMenuComponent,
Expand Down
70 changes: 70 additions & 0 deletions src/app/examples/grid-infinite-json.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<div class="demo40">
<h2>
Example 40: Infinite Scroll from JSON data
<span class="float-end">
<a style="font-size: 18px"
target="_blank"
href="https://github.com/ghiscoding/Angular-Slickgrid/blob/master/src/app/examples/grid-infinite-json.component.ts">
<span class="mdi mdi-link-variant"></span> code
</a>
</span>
</h2>

<h6 class="title is-6 italic content">
<ul>
<li>
Infinite scrolling allows the grid to lazy-load rows from the server when reaching the scroll bottom (end) position.
In its simplest form, the more the user scrolls down, the more rows get loaded.
</li>
<li>NOTES: <code>presets.pagination</code> is not supported with Infinite Scroll and will revert to the first page,
simply because since we keep appending data, we always have to start from index zero (no offset).
</li>
</ul>
</h6>

<div class="row">
<div class="col-sm-12">
<button class="btn btn-outline-secondary btn-sm" data-test="clear-filters-sorting"
(click)="clearAllFiltersAndSorts()" title="Clear all Filters & Sorts">
<span class="mdi mdi-close"></span>
<span>Clear all Filter & Sorts</span>
</button>
<button class="btn btn-outline-secondary btn-sm" data-test="set-dynamic-filter" (click)="setFiltersDynamically()">
Set Filters Dynamically
</button>
<button class="btn btn-outline-secondary btn-sm" data-test="set-dynamic-sorting" (click)="setSortingDynamically()">
Set Sorting Dynamically
</button>
<button class="btn btn-outline-secondary btn-sm" data-test="group-by-duration" (click)="groupByDuration()">
Group by Duration
</button>

<label class="ml-4">Reset Dataset <code>onSort</code>:</label>
<button class="btn btn-outline-secondary btn-sm" data-test="onsort-on" (click)="onSortReset(true)">
ON
</button>
<button class="btn btn-outline-secondary btn-sm" data-test="onsort-off" (click)="onSortReset(false)">
OFF
</button>
</div>
</div>

<div *ngIf="metrics" class="mt-2" style="margin: 10px 0px">
<b>Metrics:</b>
<span>
<span>{{metrics.endTime | date: 'dd MMM, h:mm:ssa'}}</span>
<span data-test="totalItemCount">{{metrics.totalItemCount}}</span>
items
</span>
</div>

<angular-slickgrid gridId="grid40"
[columnDefinitions]="columnDefinitions"
[gridOptions]="gridOptions"
[dataset]="dataset"
(onAngularGridCreated)="angularGridReady($event.detail)"
(onSort)="handleOnSort()"
(onScroll)="handleOnScroll($event.detail.args)"
(onRowCountChanged)="refreshMetrics($event.detail.args)">
</angular-slickgrid>
</div>
168 changes: 168 additions & 0 deletions src/app/examples/grid-infinite-json.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { Component, OnInit } from '@angular/core';
import {
type AngularGridInstance,
Aggregators,
type Column,
FieldType,
Formatters,
type GridOption,
type Grouping,
type Metrics,
type OnRowCountChangedEventArgs,
SortComparers,
SortDirectionNumber
} from '../modules/angular-slickgrid';

const FETCH_SIZE = 50;

@Component({
templateUrl: './grid-infinite-json.component.html'
})
export class GridInfiniteJsonComponent implements OnInit {
angularGrid!: AngularGridInstance;
columnDefinitions!: Column[];
dataset: any[] = [];
gridOptions!: GridOption;
metrics!: Partial<Metrics>;
scrollEndCalled = false;
shouldResetOnSort = false;

ngOnInit(): void {
this.defineGrid();
this.dataset = this.loadData(0, FETCH_SIZE);
this.metrics = {
itemCount: FETCH_SIZE,
totalItemCount: FETCH_SIZE,
};
}

angularGridReady(angularGrid: AngularGridInstance) {
this.angularGrid = angularGrid;
}

defineGrid() {
this.columnDefinitions = [
{ id: 'title', name: 'Title', field: 'title', sortable: true, minWidth: 100, filterable: true },
{ id: 'duration', name: 'Duration (days)', field: 'duration', sortable: true, minWidth: 100, filterable: true, type: FieldType.number },
{ id: 'percentComplete', name: '% Complete', field: 'percentComplete', sortable: true, minWidth: 100, filterable: true, type: FieldType.number },
{ id: 'start', name: 'Start', field: 'start', formatter: Formatters.dateIso, exportWithFormatter: true, filterable: true },
{ id: 'finish', name: 'Finish', field: 'finish', formatter: Formatters.dateIso, exportWithFormatter: true, filterable: true },
{ id: 'effort-driven', name: 'Effort Driven', field: 'effortDriven', sortable: true, minWidth: 100, filterable: true, formatter: Formatters.checkmarkMaterial }
];

this.gridOptions = {
autoResize: {
container: '#demo-container',
rightPadding: 10
},
enableAutoResize: true,
enableFiltering: true,
enableGrouping: true,
editable: false,
rowHeight: 33,
};
}

// add onScroll listener which will detect when we reach the scroll end
// if so, then append items to the dataset
handleOnScroll(args: any) {
const viewportElm = args.grid.getViewportNode();
if (
['mousewheel', 'scroll'].includes(args.triggeredBy || '')
&& !this.scrollEndCalled
&& viewportElm.scrollTop > 0
&& Math.ceil(viewportElm.offsetHeight + args.scrollTop) >= args.scrollHeight
) {
console.log('onScroll end reached, add more items');
const startIdx = this.angularGrid.dataView?.getItemCount() || 0;
const newItems = this.loadData(startIdx, FETCH_SIZE);
this.angularGrid.dataView?.addItems(newItems);
this.scrollEndCalled = false;
}
}

// do we want to reset the dataset when Sorting?
// if answering Yes then use the code below
handleOnSort() {
if (this.shouldResetOnSort) {
const newData = this.loadData(0, FETCH_SIZE);
this.angularGrid.slickGrid?.scrollTo(0); // scroll back to top to avoid unwanted onScroll end triggered
this.angularGrid.dataView?.setItems(newData);
this.angularGrid.dataView?.reSort();
}
}

groupByDuration() {
this.angularGrid?.dataView?.setGrouping({
getter: 'duration',
formatter: (g) => `Duration: ${g.value} <span class="text-green">(${g.count} items)</span>`,
comparer: (a, b) => SortComparers.numeric(a.value, b.value, SortDirectionNumber.asc),
aggregators: [
new Aggregators.Avg('percentComplete'),
new Aggregators.Sum('cost')
],
aggregateCollapsed: false,
lazyTotalsCalculation: true
} as Grouping);

// you need to manually add the sort icon(s) in UI
this.angularGrid?.slickGrid?.setSortColumns([{ columnId: 'duration', sortAsc: true }]);
this.angularGrid?.slickGrid?.invalidate(); // invalidate all rows and re-render
}

loadData(startIdx: number, count: number) {
const tmpData: any[] = [];
for (let i = startIdx; i < startIdx + count; i++) {
tmpData.push(this.newItem(i));
}

return tmpData;
}

newItem(idx: number) {
const randomYear = 2000 + Math.floor(Math.random() * 10);
const randomMonth = Math.floor(Math.random() * 11);
const randomDay = Math.floor((Math.random() * 29));
const randomPercent = Math.round(Math.random() * 100);

return {
id: idx,
title: 'Task ' + idx,
duration: Math.round(Math.random() * 100) + '',
percentComplete: randomPercent,
start: new Date(randomYear, randomMonth + 1, randomDay),
finish: new Date(randomYear + 1, randomMonth + 1, randomDay),
effortDriven: (idx % 5 === 0)
};
}

onSortReset(shouldReset: boolean) {
this.shouldResetOnSort = shouldReset;
}

clearAllFiltersAndSorts() {
if (this.angularGrid?.gridService) {
this.angularGrid.gridService.clearAllFiltersAndSorts();
}
}

setFiltersDynamically() {
// we can Set Filters Dynamically (or different filters) afterward through the FilterService
this.angularGrid?.filterService.updateFilters([
{ columnId: 'percentComplete', searchTerms: ['50'], operator: '>=' },
]);
}

refreshMetrics(args: OnRowCountChangedEventArgs) {
if (this.angularGrid && args?.current >= 0) {
this.metrics.itemCount = this.angularGrid.dataView?.getFilteredItemCount() || 0;
this.metrics.totalItemCount = args.itemCount || 0;
}
}

setSortingDynamically() {
this.angularGrid?.sortService.updateSorting([
{ columnId: 'title', direction: 'DESC' },
]);
}
}
110 changes: 110 additions & 0 deletions test/cypress/e2e/example40.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
describe('Example 40 - Infinite Scroll from JSON data', () => {
const GRID_ROW_HEIGHT = 33;
const titles = ['Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven'];

it('should display Example title', () => {
cy.visit(`${Cypress.config('baseUrl')}/infinite-json`);
cy.get('h2').should('contain', 'Example 40: Infinite Scroll from JSON data');
});

it('should have exact Column Titles in the grid', () => {
cy.get('#grid40')
.find('.slick-header-columns')
.children()
.each(($child, index) => expect($child.text()).to.eq(titles[index]));
});

it('should expect first row to include "Task 0" and other specific properties', () => {
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 0');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).contains(/[0-9]/);
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(2)`).contains(/[0-9]/);
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(3)`).contains(/20[0-9]{2}-[0-9]{2}-[0-9]{2}/);
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(4)`).contains(/20[0-9]{2}-[0-9]{2}-[0-9]{2}/);
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(5)`).find('.mdi.mdi-check').should('have.length', 1);
});

it('should scroll to bottom of the grid and expect next batch of 50 items appended to current dataset for a total of 100 items', () => {
cy.get('[data-test="totalItemCount"]')
.should('have.text', '50');

cy.get('.slick-viewport.slick-viewport-top.slick-viewport-left')
.scrollTo('bottom');

cy.get('[data-test="totalItemCount"]')
.should('have.text', '100');
});

it('should scroll to bottom of the grid again and expect 50 more items for a total of now 150 items', () => {
cy.get('[data-test="totalItemCount"]')
.should('have.text', '100');

cy.get('.slick-viewport.slick-viewport-top.slick-viewport-left')
.scrollTo('bottom');

cy.get('[data-test="totalItemCount"]')
.should('have.text', '150');
});

it('should disable onSort for data reset and expect same dataset length of 150 items after sorting by Title', () => {
cy.get('[data-test="onsort-off"]').click();

cy.get('[data-id="title"]')
.click();

cy.get('[data-test="totalItemCount"]')
.should('have.text', '150');

cy.get('.slick-viewport.slick-viewport-top.slick-viewport-left')
.scrollTo('top');

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 10');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 100');
});

it('should enable onSort for data reset and expect dataset to be reset to 50 items after sorting by Title', () => {
cy.get('[data-test="onsort-on"]').click();

cy.get('[data-id="title"]')
.click();

cy.get('[data-test="totalItemCount"]')
.should('have.text', '50');

cy.get('.slick-viewport.slick-viewport-top.slick-viewport-left')
.scrollTo('top');

cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 9');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 8');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 7');
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(0)`).should('contain', 'Task 6');
});

it('should "Group by Duration" and expect 50 items grouped', () => {
cy.get('[data-test="group-by-duration"]').click();

cy.get('[data-test="totalItemCount"]')
.should('have.text', '50');

cy.get('.slick-viewport.slick-viewport-top.slick-viewport-left')
.scrollTo('top');

cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0) .slick-group-toggle.expanded`).should('have.length', 1);
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0) .slick-group-title`).contains(/Duration: [0-9]/);
});

it('should scroll to the bottom "Group by Duration" and expect 50 more items for a total of 100 items grouped', () => {
cy.get('.slick-viewport.slick-viewport-top.slick-viewport-left')
.scrollTo('bottom');

cy.get('[data-test="totalItemCount"]')
.should('have.text', '100');

cy.get('.slick-viewport.slick-viewport-top.slick-viewport-left')
.scrollTo('top');

cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0) .slick-group-toggle.expanded`).should('have.length', 1);
cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0) .slick-group-title`).contains(/Duration: [0-9]/);
});
});

0 comments on commit 251a984

Please sign in to comment.