From a982443a65eded0ae948ec49386445c44cc7802a Mon Sep 17 00:00:00 2001 From: "Ghislain B." Date: Tue, 6 Aug 2024 21:13:05 -0400 Subject: [PATCH] feat: Infinite Scroll for JSON data (#1245) * feat: Infinite Scroll for JSON data --- .../src/examples/slickgrid/example40.html | 71 ++++++++ .../demo/src/examples/slickgrid/example40.ts | 164 ++++++++++++++++++ packages/demo/src/my-app.ts | 1 + test/cypress/e2e/example40.cy.ts | 110 ++++++++++++ 4 files changed, 346 insertions(+) create mode 100644 packages/demo/src/examples/slickgrid/example40.html create mode 100644 packages/demo/src/examples/slickgrid/example40.ts create mode 100644 test/cypress/e2e/example40.cy.ts diff --git a/packages/demo/src/examples/slickgrid/example40.html b/packages/demo/src/examples/slickgrid/example40.html new file mode 100644 index 000000000..40dd695f1 --- /dev/null +++ b/packages/demo/src/examples/slickgrid/example40.html @@ -0,0 +1,71 @@ +
+

+ Example 40: Infinite Scroll from JSON data + + + code + + +

+ +
+
    +
  • + 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. +
  • +
  • NOTES: presets.pagination 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). +
  • +
+
+ +
+
+ + + + + + + + +
+
+ +
+ Metrics: + + ${metrics.endTime | dateFormat: 'DD MMM, h:mm:ss a'} — + ${metrics.totalItemCount} + items + +
+ + + +
diff --git a/packages/demo/src/examples/slickgrid/example40.ts b/packages/demo/src/examples/slickgrid/example40.ts new file mode 100644 index 000000000..d31964452 --- /dev/null +++ b/packages/demo/src/examples/slickgrid/example40.ts @@ -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; + 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} (${g.count} items)`, + 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' }, + ]); + } +} diff --git a/packages/demo/src/my-app.ts b/packages/demo/src/my-app.ts index 636b4e7a6..c2810c515 100644 --- a/packages/demo/src/my-app.ts +++ b/packages/demo/src/my-app.ts @@ -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' }, ]; diff --git a/test/cypress/e2e/example40.cy.ts b/test/cypress/e2e/example40.cy.ts new file mode 100644 index 000000000..cbc166bc7 --- /dev/null +++ b/test/cypress/e2e/example40.cy.ts @@ -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]/); + }); +});