diff --git a/public/docs/_examples/cb-virtual-grid/e2e-spec.ts b/public/docs/_examples/cb-virtual-grid/e2e-spec.ts new file mode 100644 index 0000000000..ff4787430f --- /dev/null +++ b/public/docs/_examples/cb-virtual-grid/e2e-spec.ts @@ -0,0 +1,49 @@ +/// +'use strict'; + +describe('Virtual Grid', function () { + + beforeEach(function () { + browser.get(''); + }); + + it('should sort the grid', function() { + ValidateRankingColumn(['1000', '999', '998', '997', '996', '995', '994', '993', '992', '991', '990']); + + // click Ranking column and make sure values are sorted + element(by.xpath('//tbody/tr[1]/td[3]')).click().then(function(){ + ValidateRankingColumn(['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']); + }); + }); + + function ValidateRankingColumn(range: string[]) { + for (let i = 0; i < range.length; i++) { + let rankCell = element.all(by.xpath('//tbody/tr[' + (i + 2) + ']/td[3]//input')).get(0); + expect(rankCell.getAttribute('value')).toEqual(range[i]); + } + } + + it('should bind to new row but keep DOM constant', function () { + + let lastRowFirstTextCell = element(by.css('#cell10-0')); + + // check that the last row number in the grid is 10 + expect(element(by.xpath('//tbody/tr[12]/td[1][contains(text(),10)]')).isPresent()).toBe(true); + + let trs = element.all(by.xpath('//tbody/tr')); + expect(trs.count()).toBe(12); + + lastRowFirstTextCell.click().then(function(){ + lastRowFirstTextCell.clear(); + lastRowFirstTextCell.sendKeys('New Hero'); + expect(lastRowFirstTextCell.getAttribute('value')).toEqual('New Hero'); + + // page down one row + lastRowFirstTextCell.sendKeys(protractor.Key.ARROW_DOWN); + // navigating does not increase the number of rows + trs = element.all(by.xpath('//tbody/tr')); + expect(trs.count()).toBe(12); + }); + }); +}); + diff --git a/public/docs/_examples/cb-virtual-grid/ts/app/.gitignore b/public/docs/_examples/cb-virtual-grid/ts/app/.gitignore new file mode 100644 index 0000000000..2cb7d2a2e9 --- /dev/null +++ b/public/docs/_examples/cb-virtual-grid/ts/app/.gitignore @@ -0,0 +1 @@ +**/*.js diff --git a/public/docs/_examples/cb-virtual-grid/ts/app/app.component.ts b/public/docs/_examples/cb-virtual-grid/ts/app/app.component.ts new file mode 100644 index 0000000000..676a44966d --- /dev/null +++ b/public/docs/_examples/cb-virtual-grid/ts/app/app.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'my-app', + template: '' +}) + +export class AppComponent { +} diff --git a/public/docs/_examples/cb-virtual-grid/ts/app/app.module.ts b/public/docs/_examples/cb-virtual-grid/ts/app/app.module.ts new file mode 100644 index 0000000000..dfe69ff33e --- /dev/null +++ b/public/docs/_examples/cb-virtual-grid/ts/app/app.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; + +import { AppComponent } from './app.component'; +import { CellComponent } from './cell.component'; +import { HeroGridComponent } from './hero-grid.component'; + +@NgModule({ + imports: [ BrowserModule ], + declarations: [ AppComponent, CellComponent, HeroGridComponent ], + bootstrap: [ AppComponent ] +}) +export class AppModule {} diff --git a/public/docs/_examples/cb-virtual-grid/ts/app/cell.component.ts b/public/docs/_examples/cb-virtual-grid/ts/app/cell.component.ts new file mode 100644 index 0000000000..bf90c3ba72 --- /dev/null +++ b/public/docs/_examples/cb-virtual-grid/ts/app/cell.component.ts @@ -0,0 +1,39 @@ +// #docregion +import { Component, Input, Output, EventEmitter, ElementRef, Renderer, ViewChild } from '@angular/core'; + +import { Column } from './column'; +import { HeroGridService } from './hero-grid.service'; +import { KeyCodeService } from './key-code.service'; + +@Component({ + selector: 'grid-cell', + template: `` +}) + +export class CellComponent { + @Input() col: Column; + @Input() id: string; + @Output() navigate = new EventEmitter(); + @ViewChild('input') input: ElementRef; + + constructor(public heroGridService: HeroGridService, private elementRef: ElementRef, + private renderer: Renderer, private keyCodeService: KeyCodeService) { + } + + select(): void { + this.renderer.invokeElementMethod(this.input.nativeElement, 'focus'); + } + + onKeyDown(e: any): boolean { + let key = this.keyCodeService.getNavigationKey(e.keyCode); + if (key.isArrowKey) { + this.navigate.emit(e); + } + return !key.tab; + } +} + diff --git a/public/docs/_examples/cb-virtual-grid/ts/app/column.ts b/public/docs/_examples/cb-virtual-grid/ts/app/column.ts new file mode 100644 index 0000000000..5f1c4d50a6 --- /dev/null +++ b/public/docs/_examples/cb-virtual-grid/ts/app/column.ts @@ -0,0 +1,12 @@ +// #docregion +import { Row } from './row'; + +export class Column { + cellValue: string; + row: Row; + + constructor(public columnIndex: number, row: Row) { + this.cellValue = ''; + this.row = row; + } +} diff --git a/public/docs/_examples/cb-virtual-grid/ts/app/hero-data.service.ts b/public/docs/_examples/cb-virtual-grid/ts/app/hero-data.service.ts new file mode 100644 index 0000000000..24070b475c --- /dev/null +++ b/public/docs/_examples/cb-virtual-grid/ts/app/hero-data.service.ts @@ -0,0 +1,44 @@ +// #docregion +import { Injectable } from '@angular/core'; + +import { Row } from './row'; + +@Injectable() +export class HeroDataService { + + getApplicants(count: number): Row[] { + let rows: Row[] = []; + let heroes: string[] = ['Mr. Nice', + 'Narco', + 'Bombasto', + 'Celeritas', + 'Magneta', + 'RubberMan', + 'Dynama', + 'Dr IQ', + 'Magma', + 'Tornado' + ]; + + for (let i = 0; i < count; i++) { + let heroIndex = this.generateRandomNumber(heroes.length - 1); + let heroData = [heroes[heroIndex], count - i, this.generateRandomNumber(70) + 30]; + rows.push(this.createCell(i, heroData)); + } + return rows; + } + + private generateRandomNumber(upperBound: number): number { + return Math.floor(Math.random() * upperBound); + } + + private createCell(index: number, values: Array): Row { + let row = new Row(values.length); + + for (let i = 0; i < values.length; i++) { + row.columns[i].cellValue = values[i]; + } + return row; + } +} + diff --git a/public/docs/_examples/cb-virtual-grid/ts/app/hero-grid-sorting.service.ts b/public/docs/_examples/cb-virtual-grid/ts/app/hero-grid-sorting.service.ts new file mode 100644 index 0000000000..c93dcab0db --- /dev/null +++ b/public/docs/_examples/cb-virtual-grid/ts/app/hero-grid-sorting.service.ts @@ -0,0 +1,26 @@ +// #docregion +import { Injectable } from '@angular/core'; + +import { Row } from './row'; + +@Injectable() +export class HeroGridSortingService { + sortDirection = 1; + + sort(rows: Array, colIndex: number): void { + this.sortDirection *= -1; + rows.sort((a, b) => { + if (a.columns[colIndex].cellValue === b.columns[colIndex].cellValue) { + return 0; + } + + if (a.columns[colIndex].cellValue > b.columns[colIndex].cellValue) { + return -1 * this.sortDirection; + } + + if (a.columns[colIndex].cellValue < b.columns[colIndex].cellValue) { + return 1 * this.sortDirection; + } + }); + } +} diff --git a/public/docs/_examples/cb-virtual-grid/ts/app/hero-grid.component.ts b/public/docs/_examples/cb-virtual-grid/ts/app/hero-grid.component.ts new file mode 100644 index 0000000000..f74fd8b4d7 --- /dev/null +++ b/public/docs/_examples/cb-virtual-grid/ts/app/hero-grid.component.ts @@ -0,0 +1,61 @@ +// #docregion +import { Component, AfterViewChecked, ViewChildren, QueryList } from '@angular/core'; + +import { CellComponent } from './cell.component'; +import { HeroGridSortingService } from './hero-grid-sorting.service'; +import { HeroGridService } from './hero-grid.service'; +import { KeyCodeService } from './key-code.service'; +import { HeroDataService } from './hero-data.service'; +import { Row } from './row'; + +@Component({ + selector: 'hero-grid', + providers: [HeroGridService, KeyCodeService, HeroDataService, HeroGridSortingService], + template: `

Hero Grid

+ + + + + + + + + +
+ {{columnHeader}} +
+ {{heroGridService.rows.indexOf(row)}} + + + +
` +}) + +export class HeroGridComponent implements AfterViewChecked { + + visibleRows: Array = []; + @ViewChildren(CellComponent) cells: QueryList; + + constructor(public heroGridService: HeroGridService) { + this.visibleRows = this.heroGridService.getVisibleRows(); + } + + navigate($event: any): void { + this.heroGridService.navigate($event.keyCode); + this.visibleRows = this.heroGridService.getVisibleRows(); + } + + sort(columnIndex: number): void { + this.heroGridService.sort(columnIndex); + this.visibleRows = this.heroGridService.getVisibleRows(); + } + + ngAfterViewChecked(): void { + let id = this.heroGridService.getCurrentCellSelector(); + let currentCell = this.cells.toArray().find(cell => cell.id === id); + currentCell.select(); + } +} diff --git a/public/docs/_examples/cb-virtual-grid/ts/app/hero-grid.service.ts b/public/docs/_examples/cb-virtual-grid/ts/app/hero-grid.service.ts new file mode 100644 index 0000000000..84f54b460e --- /dev/null +++ b/public/docs/_examples/cb-virtual-grid/ts/app/hero-grid.service.ts @@ -0,0 +1,119 @@ +// #docregion +import { Injectable } from '@angular/core'; + +import { Column } from './column'; +import { HeroDataService } from './hero-data.service'; +import { HeroGridSortingService } from './hero-grid-sorting.service'; +import { KeyCodeService } from './key-code.service'; +import { Row } from './row'; + +@Injectable() +export class HeroGridService { + static maxRows = 1000; + header: string[] = ['Name', 'Ranking', 'Age']; + rows: Row[]; + currentColumn: Column; + currentRowIndex = 0; + + private gridWindow: any; + + constructor(private keyCodeService: KeyCodeService, private sortingService: HeroGridSortingService, heroDataService: HeroDataService) { + this.rows = heroDataService.getApplicants(HeroGridService.maxRows); + + this.init(); + + let missingRows = this.gridWindow.pageSize - this.rows.length; + + for (let i = 0; i <= missingRows; i++) { + this.rows.push(new Row(this.header.length)); + } + } + + selectColumn(col: Column): void { + this.currentColumn = col; + this.currentRowIndex = this.rows.indexOf(this.currentColumn.row); + } + + sort(colIndex: number): void { + this.sortingService.sort(this.rows, colIndex); + this.init(); + } + + createCellSelector(row: Row, col: Column): string { + return 'cell' + this.rows.indexOf(row) + '-' + row.columns.indexOf(col); + } + + getCurrentCellSelector(): string { + let cellIndex = this.rows[this.currentRowIndex].columns.indexOf(this.currentColumn); + return 'cell' + this.currentRowIndex + '-' + cellIndex; + } + + getVisibleRows(): Row[] { + let visible: Row[] = []; + for (let i = this.gridWindow.start; i <= this.gridWindow.end; i++) { + visible.push(this.rows[i]); + } + return visible; + } + + navigate(keyCode: number): void { + let navDirection = this.keyCodeService.getNavigationKey(keyCode); + + if (navDirection.down) { + this.ensureRow(); + this.currentColumn = this.rows[this.currentRowIndex + 1].columns[this.currentColumn.columnIndex]; + this.adjustRowRangeDownward(); + } + + if (navDirection.up) { + if (this.currentRowIndex > 0) { + this.currentColumn = this.rows[this.currentRowIndex - 1].columns[this.currentColumn.columnIndex]; + this.adjustRowRangeUpward(); + } + } + + if (navDirection.left) { + if (this.currentColumn.columnIndex > 0) { + this.currentColumn = this.rows[this.currentRowIndex].columns[this.currentColumn.columnIndex - 1]; + } + } + + if (navDirection.right) { + if (this.currentColumn.columnIndex < this.header.length - 1) { + this.currentColumn = this.rows[this.currentRowIndex].columns[this.currentColumn.columnIndex + 1]; + } + } + + this.currentRowIndex = this.rows.indexOf(this.currentColumn.row); + } + + private adjustRowRangeUpward(): void { + if (this.currentRowIndex <= this.gridWindow.start) { + this.shiftRowsBy(-1); + } + } + + private adjustRowRangeDownward(): void { + if (this.currentRowIndex === this.gridWindow.end) { + this.shiftRowsBy(1); + } + } + + private shiftRowsBy(offset: number): void { + this.gridWindow.start = this.gridWindow.start + offset; + this.gridWindow.end = this.gridWindow.end + offset; + } + + private ensureRow(): void { + if (this.currentRowIndex + 1 >= this.rows.length) { + this.rows.push(new Row(this.header.length)); + } + } + + private init(): void { + this.gridWindow = {pageSize: 10, start: 0, end: 10}; + this.currentColumn = this.rows[0].columns[0]; + this.currentRowIndex = 0; + } +} + diff --git a/public/docs/_examples/cb-virtual-grid/ts/app/key-code.service.ts b/public/docs/_examples/cb-virtual-grid/ts/app/key-code.service.ts new file mode 100644 index 0000000000..c6a087a54d --- /dev/null +++ b/public/docs/_examples/cb-virtual-grid/ts/app/key-code.service.ts @@ -0,0 +1,20 @@ +// #docregion +import { Injectable } from '@angular/core'; + +@Injectable() +export class KeyCodeService { + + getNavigationKey(keyCode: number): any { + let key: any = { + up: keyCode === 38, + down: keyCode === 40, + right: keyCode === 39, + left: keyCode === 37, + tab: keyCode === 9, + }; + key.isArrowKey = key.up || key.down || key.right || key.left; + + return key; + } +} + diff --git a/public/docs/_examples/cb-virtual-grid/ts/app/main.ts b/public/docs/_examples/cb-virtual-grid/ts/app/main.ts new file mode 100644 index 0000000000..6af7a5b2ae --- /dev/null +++ b/public/docs/_examples/cb-virtual-grid/ts/app/main.ts @@ -0,0 +1,5 @@ +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app.module'; + +platformBrowserDynamic().bootstrapModule(AppModule); diff --git a/public/docs/_examples/cb-virtual-grid/ts/app/row.ts b/public/docs/_examples/cb-virtual-grid/ts/app/row.ts new file mode 100644 index 0000000000..824036869f --- /dev/null +++ b/public/docs/_examples/cb-virtual-grid/ts/app/row.ts @@ -0,0 +1,15 @@ +// #docregion +import { Column } from './column'; + +export class Row { + + columns: Array; + + constructor(public columnCount: number) { + this.columns = []; + + for (let j = 0; j < this.columnCount; j++) { + this.columns.push(new Column(j, this)); + } + } +} diff --git a/public/docs/_examples/cb-virtual-grid/ts/demo.css b/public/docs/_examples/cb-virtual-grid/ts/demo.css new file mode 100644 index 0000000000..cc1e641ea2 --- /dev/null +++ b/public/docs/_examples/cb-virtual-grid/ts/demo.css @@ -0,0 +1,23 @@ +.row-number-column{ + width: 40px; + background-color: #eeeeee; + text-align: center; +} + +#hero-grid td{ + border: 1px solid gray; +} + +#hero-grid input{ + border:0; +} + +#hero-grid .columnHeader{ + background-color: #eeeeee; + text-transform: uppercase; + text-align: center; +} + +#hero-grid{ + border-collapse: collapse; +} diff --git a/public/docs/_examples/cb-virtual-grid/ts/example-config.json b/public/docs/_examples/cb-virtual-grid/ts/example-config.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/public/docs/_examples/cb-virtual-grid/ts/index.html b/public/docs/_examples/cb-virtual-grid/ts/index.html new file mode 100644 index 0000000000..a13c5d0946 --- /dev/null +++ b/public/docs/_examples/cb-virtual-grid/ts/index.html @@ -0,0 +1,29 @@ + + + + + Virtual Grid + + + + + + + + + + + + + + + + + loading... + + + diff --git a/public/docs/_examples/cb-virtual-grid/ts/plnkr.json b/public/docs/_examples/cb-virtual-grid/ts/plnkr.json new file mode 100644 index 0000000000..50b17a7a26 --- /dev/null +++ b/public/docs/_examples/cb-virtual-grid/ts/plnkr.json @@ -0,0 +1,8 @@ +{ + "description": "Virtual Grid Cookbook samples", + "files":[ + "!**/*.d.ts", + "!**/*.js" + ], + "tags":["cookbook", "component"] +} \ No newline at end of file diff --git a/public/docs/ts/latest/cookbook/_data.json b/public/docs/ts/latest/cookbook/_data.json index f82d816cef..ecd862e394 100644 --- a/public/docs/ts/latest/cookbook/_data.json +++ b/public/docs/ts/latest/cookbook/_data.json @@ -58,6 +58,11 @@ "intro": "Convert Angular 2 TypeScript examples into ES5 JavaScript" }, + "virtual-grid": { + "title": "Virtual Grid", + "description": "Scalable grid based on virtualization" + }, + "visual-studio-2015": { "title": "Visual Studio 2015 QuickStart", "intro": "Use Visual Studio 2015 with the QuickStart files" diff --git a/public/docs/ts/latest/cookbook/virtual-grid.jade b/public/docs/ts/latest/cookbook/virtual-grid.jade new file mode 100644 index 0000000000..8e34efcaf6 --- /dev/null +++ b/public/docs/ts/latest/cookbook/virtual-grid.jade @@ -0,0 +1,90 @@ +include ../_util-fns + + +:marked + Well, it's application season again, and as usual we have been inundated with applications from eager heroes. This year's surge in applications has forced us to improve the performance of the grid we use to display information about the heroes. + + In this cookbook we show how use virtualization to create a highly scalable grid, capable of handling thousands or records and still be responsive. Rendering elements to the DOM is slow, so the idea behind virtualization is to only render a fixed number of DOM elements based on a sliding window into the full dataset. Currently the grid supports basic features like sorting and key based navigation, but we might expand the feature set over time. + + +:marked + ## Table of contents + + [Grid Component](#grid-component) + + [Grid Service](#grid-service) + + [Grid Sorting](#grid-sorting) + + [Object Model](#object-model) + + [Key Codes](#key-codes) + + [Large Dataset](#large-dataset) + +:marked + **See the [live example](/resources/live-examples/cb-virtual-grid/ts/plnkr.html)**. + +.l-main-section + +:marked + ## Grid Component + + The first step is to create a simple `HeroGridComponent` with support for arrow key navigation and sorting. + + `HeroGrid` defines the necessary template and binding logic to render the set of visible rows. + ++makeExample('cb-virtual-grid/ts/app/hero-grid.component.ts',null,'app/hero-grid.component.ts')(format='.') + + +:marked + ## Grid Service + +:marked + Next we have defined `HeroGridService` to manage the entire set of rows and columns in the grid. `HeroGridService` controls the sliding window of currently visible rows. We will only render UI elements for visible rows. Based on user actions `HeroGridService` will recalculate the set of visible rows from the full dataset. ++makeExample('cb-virtual-grid/ts/app/hero-grid.service.ts',null,'app/hero-grid.service.ts')(format='.') + + +:marked + ## Grid Sorting + +:marked + We are doing the sorting in `HeroGridSortingService`. ++makeExample('cb-virtual-grid/ts/app/hero-grid-sorting.service.ts',null,'app/hero-grid-sorting.service.ts')(format='.') + + +:marked + ## Object Model +:marked + The grid is bound to an object model consisting of `Row` and `Column` objects. + ++makeExample('cb-virtual-grid/ts/app/row.ts',null,'app/row.ts')(format='.') + ++makeExample('cb-virtual-grid/ts/app/column.ts',null,'app/column.ts')(format='.') + + +:marked + ## Key Codes + +:marked + The grid supports arrow key based navigation, so to map supported keys we have created `KeyCodeService`. + ++makeExample('cb-virtual-grid/ts/app/key-code.service.ts',null,'app/key-code.service.ts')(format='.') + + +:marked + ## Large Dataset + +:marked + We are getting the list of applicants from `HeroDataService`. Typically this data would come from a database, but for demo purposes we are using `HeroDataService` to simulate a large datset. Despite the large dataset we can see that the grid performs really well when sorting and paging through the data. + + Paging through the data does not cause the grid to render more UI elements, so if we inspect the DOM we will see that the number of `tr` elements remains fixed. Instead we data-bind a sliding window of `Row` and `Column` objects to the same set of `tr` elements. Not increasing the number of rows to render is key to performance since DOM rendering is the slowest operation. ++makeExample('cb-virtual-grid/ts/app/hero-data.service.ts',null,'app/hero-data.service.ts')(format='.') + +:marked + The final grid looks like this: +figure.image-display + img(src="/resources/images/cookbooks/virtual-grid/virtual-grid.png" alt="Virtual-Grid") + +:marked + [Back to top](#top) \ No newline at end of file diff --git a/public/resources/images/cookbooks/virtual-grid/virtual-grid.png b/public/resources/images/cookbooks/virtual-grid/virtual-grid.png new file mode 100644 index 0000000000..ad8cc8ffba Binary files /dev/null and b/public/resources/images/cookbooks/virtual-grid/virtual-grid.png differ