diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 3a5753c3..33ba92ec 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -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'; @@ -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 }, diff --git a/src/app/app.component.html b/src/app/app.component.html index d5b6cc7a..3e13aedc 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -186,6 +186,11 @@ 39- Infinite Scroll with GraphQL + diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 5e48e344..311a5401 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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'; @@ -121,6 +122,7 @@ export function appInitializerFactory(translate: TranslateService, injector: Inj GridHeaderFooterComponent, GridHeaderMenuComponent, GridInfiniteGraphqlComponent, + GridInfiniteJsonComponent, GridInfiniteOdataComponent, GridLocalizationComponent, GridMenuComponent, diff --git a/src/app/examples/grid-infinite-json.component.html b/src/app/examples/grid-infinite-json.component.html new file mode 100644 index 00000000..9909e39f --- /dev/null +++ b/src/app/examples/grid-infinite-json.component.html @@ -0,0 +1,70 @@ +
+

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

+ +
+ +
+ +
+
+ + + + + + + + +
+
+ +
+ Metrics: + + {{metrics.endTime | date: 'dd MMM, h:mm:ssa'}} — + {{metrics.totalItemCount}} + items + +
+ + + +
\ No newline at end of file diff --git a/src/app/examples/grid-infinite-json.component.ts b/src/app/examples/grid-infinite-json.component.ts new file mode 100644 index 00000000..a0a6e092 --- /dev/null +++ b/src/app/examples/grid-infinite-json.component.ts @@ -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; + 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} (${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.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' }, + ]); + } +} diff --git a/test/cypress/e2e/example40.cy.ts b/test/cypress/e2e/example40.cy.ts new file mode 100644 index 00000000..829412dd --- /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')}/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]/); + }); +});