Skip to content

Commit

Permalink
feat: Infinite Scroll for JSON data (#1245)
Browse files Browse the repository at this point in the history
* feat: Infinite Scroll for JSON data
  • Loading branch information
ghiscoding authored Aug 7, 2024
1 parent c19ddbb commit a982443
Show file tree
Hide file tree
Showing 4 changed files with 346 additions and 0 deletions.
71 changes: 71 additions & 0 deletions packages/demo/src/examples/slickgrid/example40.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<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/aurelia-slickgrid/blob/master/packages/demo/src/examples/slickgrid/example40.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.trigger="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.trigger="setFiltersDynamically()">
Set Filters Dynamically
</button>
<button class="btn btn-outline-secondary btn-sm" data-test="set-dynamic-sorting" click.trigger="setSortingDynamically()">
Set Sorting Dynamically
</button>
<button class="btn btn-outline-secondary btn-sm" data-test="group-by-duration" click.trigger="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.trigger="onSortReset(true)">
ON
</button>
<button class="btn btn-outline-secondary btn-sm" data-test="onsort-off" click.trigger="onSortReset(false)">
OFF
</button>
</div>
</div>

<div show.bind="metrics" class="mt-2" style="margin: 10px 0px">
<b>Metrics:</b>
<span>
<span>${metrics.endTime | dateFormat: 'DD MMM, h:mm:ss a'}</span>
<span data-test="totalItemCount">${metrics.totalItemCount}</span>
items
</span>
</div>

<aurelia-slickgrid
grid-id="grid40"
column-definitions.bind="columnDefinitions"
grid-options.bind="gridOptions"
dataset.bind="dataset"
instances.bind="aureliaGrid"
on-aurelia-grid-created.trigger="aureliaGridReady($event.detail)"
on-row-count-changed.trigger="refreshMetrics($event.detail.args)"
on-sort.trigger="handleOnSort()"
on-scroll.trigger="handleOnScroll($event.detail.args)">
</aurelia-slickgrid>
</div>
164 changes: 164 additions & 0 deletions packages/demo/src/examples/slickgrid/example40.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import {
type AureliaGridInstance,
Aggregators,
type Column,
FieldType,
Formatters,
type GridOption,
type Grouping,
type Metrics,
type OnRowCountChangedEventArgs,
SortComparers,
SortDirectionNumber,
} from 'aurelia-slickgrid';

const FETCH_SIZE = 50;

export class Example40 {
aureliaGrid: AureliaGridInstance;
columnDefinitions!: Column[];
dataset: any[] = [];
gridOptions!: GridOption;
metrics!: Partial<Metrics>;
scrollEndCalled = false;
shouldResetOnSort = false;

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

aureliaGridReady(aureliaGrid: AureliaGridInstance) {
this.aureliaGrid = aureliaGrid;
}

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.aureliaGrid.dataView?.getItemCount() || 0;
const newItems = this.loadData(startIdx, FETCH_SIZE);
this.aureliaGrid.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.aureliaGrid.slickGrid?.scrollTo(0); // scroll back to top to avoid unwanted onScroll end triggered
this.aureliaGrid.dataView?.setItems(newData);
this.aureliaGrid.dataView?.reSort();
}
}

groupByDuration() {
this.aureliaGrid?.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.aureliaGrid?.slickGrid?.setSortColumns([{ columnId: 'duration', sortAsc: true }]);
this.aureliaGrid?.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.aureliaGrid?.gridService) {
this.aureliaGrid.gridService.clearAllFiltersAndSorts();
}
}

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

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

setSortingDynamically() {
this.aureliaGrid?.sortService.updateSorting([
{ columnId: 'title', direction: 'DESC' },
]);
}
}
1 change: 1 addition & 0 deletions packages/demo/src/my-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export class MyApp {
{ path: 'example37', component: () => import('./examples/slickgrid/example37'), title: '37- Footer Totals Row' },
{ path: 'example38', component: () => import('./examples/slickgrid/example38'), title: '38- Infinite Scroll with OData' },
{ path: 'example39', component: () => import('./examples/slickgrid/example39'), title: '39- Infinite Scroll with GraphQL' },
{ path: 'example40', component: () => import('./examples/slickgrid/example40'), title: '40- Infinite Scroll from JSON data' },
{ path: 'home', component: () => import('./home-page'), title: 'Home' },
];

Expand Down
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')}/example40`);
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 a982443

Please sign in to comment.