diff --git a/docs/assets/images/components/tree-grid.svg b/docs/assets/images/components/tree-grid.svg new file mode 100644 index 0000000000..59abefed8b --- /dev/null +++ b/docs/assets/images/components/tree-grid.svg @@ -0,0 +1,28 @@ + + + + icon/component/tree + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/structure.ts b/docs/structure.ts index 39d1b2c477..487fc5a07e 100644 --- a/docs/structure.ts +++ b/docs/structure.ts @@ -482,6 +482,29 @@ export const structure = [ 'NbCalendarKitModule', ], }, + { + type: 'group', + name: 'Data Table', + }, + { + type: 'tabs', + name: 'Tree Grid', + icon: 'tree-grid.svg', + source: [ + 'NbTreeGridComponent', + 'NbTreeGridNode', + 'NbTreeGridPresentationNode', + 'NbTreeGridSortService', + 'NbTreeGridFilterService', + 'NbTreeGridColumnDefDirective', + 'NbTreeGridRowDefDirective', + 'NbTreeGridRowComponent', + 'NbSortDirective', + 'NbSortHeaderComponent', + 'NbFilterInputDirective', + 'NbTreeGridRowToggleDirective', + ], + }, ], }, { diff --git a/scripts/gulp/tasks/bundle/rollup-config.ts b/scripts/gulp/tasks/bundle/rollup-config.ts index 6547b4ddc4..c5f0c9c6fe 100644 --- a/scripts/gulp/tasks/bundle/rollup-config.ts +++ b/scripts/gulp/tasks/bundle/rollup-config.ts @@ -22,6 +22,8 @@ const ROLLUP_GLOBALS = { '@angular/cdk/portal': 'ng.cdk.portal', '@angular/cdk/a11y': 'ng.cdk.a11y', '@angular/cdk/scrolling': 'ng.cdk.scrolling', + '@angular/cdk/table': 'ng.cdk.table', + '@angular/cdk/bidi': 'ng.cdk.bidi', // RxJS dependencies diff --git a/src/app/playground-components.ts b/src/app/playground-components.ts index 084045d8c8..32ce7d1ed7 100644 --- a/src/app/playground-components.ts +++ b/src/app/playground-components.ts @@ -1256,6 +1256,53 @@ export const PLAYGROUND_COMPONENTS: ComponentLink[] = [ }, ], }, + { + path: 'tree-grid', + children: [ + { + path: 'tree-grid-showcase.component', + link: '/tree-grid/tree-grid-showcase.component', + component: 'TreeGridShowcaseComponent', + name: 'Tree Grid Showcase', + }, + { + path: 'tree-grid-sortable.component', + link: '/tree-grid/tree-grid-sortable.component', + component: 'TreeGridSortableComponent', + name: 'Tree Grid Sortable', + }, + { + path: 'tree-grid-filterable.component', + link: '/tree-grid/tree-grid-filterable.component', + component: 'TreeGridFilterableComponent', + name: 'Tree Grid Filterable', + }, + { + path: 'tree-grid-basic.component', + link: '/tree-grid/tree-grid-basic.component', + component: 'TreeGridBasicComponent', + name: 'Tree Grid Basic', + }, + { + path: 'tree-grid-responsive.component', + link: '/tree-grid/tree-grid-responsive.component', + component: 'TreeGridResponsiveComponent', + name: 'Tree Grid Responsive', + }, + { + path: 'tree-grid-custom-icons.component', + link: '/tree-grid/tree-grid-custom-icons.component', + component: 'TreeGridCustomIconsComponent', + name: 'Tree Grid Custom Icons', + }, + { + path: 'tree-grid-disable-click-toggle.component', + link: '/tree-grid/tree-grid-disable-click-toggle.component', + component: 'TreeGridDisableClickToggleComponent', + name: 'Tree Grid Disable Click Toggle', + }, + ], + }, { path: 'bootstrap', children: [ diff --git a/src/framework/theme/components/cdk/bidi/bidi.module.ts b/src/framework/theme/components/cdk/bidi/bidi.module.ts new file mode 100644 index 0000000000..b151de1e1d --- /dev/null +++ b/src/framework/theme/components/cdk/bidi/bidi.module.ts @@ -0,0 +1,10 @@ +import { NgModule } from '@angular/core'; +import { BidiModule, Directionality } from '@angular/cdk/bidi'; +import { NbDirectionality } from './bidi'; + +@NgModule({ + providers: [ + { provide: NbDirectionality, useExisting: Directionality }, + ], +}) +export class NbBidiModule extends BidiModule {} diff --git a/src/framework/theme/components/cdk/bidi/bidi.ts b/src/framework/theme/components/cdk/bidi/bidi.ts new file mode 100644 index 0000000000..9e373e89ff --- /dev/null +++ b/src/framework/theme/components/cdk/bidi/bidi.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@angular/core'; +import { Directionality } from '@angular/cdk/bidi'; + +@Injectable() +export class NbDirectionality extends Directionality {} diff --git a/src/framework/theme/components/cdk/bidi/index.ts b/src/framework/theme/components/cdk/bidi/index.ts new file mode 100644 index 0000000000..5174b8f036 --- /dev/null +++ b/src/framework/theme/components/cdk/bidi/index.ts @@ -0,0 +1,2 @@ +export * from './bidi'; +export * from './bidi.module'; diff --git a/src/framework/theme/components/cdk/collections/collection-viewer.ts b/src/framework/theme/components/cdk/collections/collection-viewer.ts new file mode 100644 index 0000000000..da9fd5fcfd --- /dev/null +++ b/src/framework/theme/components/cdk/collections/collection-viewer.ts @@ -0,0 +1,6 @@ +import { CollectionViewer, ListRange } from '@angular/cdk/collections'; +import { Observable } from 'rxjs'; + +export interface NbCollectionViewer extends CollectionViewer { + viewChange: Observable; +} diff --git a/src/framework/theme/components/cdk/collections/index.ts b/src/framework/theme/components/cdk/collections/index.ts new file mode 100644 index 0000000000..9a98464a40 --- /dev/null +++ b/src/framework/theme/components/cdk/collections/index.ts @@ -0,0 +1 @@ +export * from './collection-viewer'; diff --git a/src/framework/theme/components/cdk/platform/index.ts b/src/framework/theme/components/cdk/platform/index.ts new file mode 100644 index 0000000000..9c1fc28142 --- /dev/null +++ b/src/framework/theme/components/cdk/platform/index.ts @@ -0,0 +1,2 @@ +export * from './platform.module'; +export * from './platform'; diff --git a/src/framework/theme/components/cdk/platform/platform.module.ts b/src/framework/theme/components/cdk/platform/platform.module.ts new file mode 100644 index 0000000000..a5977602ae --- /dev/null +++ b/src/framework/theme/components/cdk/platform/platform.module.ts @@ -0,0 +1,10 @@ +import { NgModule } from '@angular/core'; +import { Platform, PlatformModule } from '@angular/cdk/platform'; +import { NbPlatform } from './platform'; + +@NgModule({ + providers: [ + { provide: NbPlatform, useExisting: Platform }, + ], +}) +export class NbPlatformModule extends PlatformModule {} diff --git a/src/framework/theme/components/cdk/platform/platform.ts b/src/framework/theme/components/cdk/platform/platform.ts new file mode 100644 index 0000000000..58a49a73bd --- /dev/null +++ b/src/framework/theme/components/cdk/platform/platform.ts @@ -0,0 +1,3 @@ +import { Platform } from '@angular/cdk/platform'; + +export class NbPlatform extends Platform {} diff --git a/src/framework/theme/components/cdk/table/cell.ts b/src/framework/theme/components/cdk/table/cell.ts new file mode 100644 index 0000000000..95532554ca --- /dev/null +++ b/src/framework/theme/components/cdk/table/cell.ts @@ -0,0 +1,121 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license infornbion. + */ + +import { Directive, ElementRef, InjectionToken, Input } from '@angular/core'; +import { + CdkCell, + CdkCellDef, + CdkColumnDef, + CdkFooterCell, + CdkFooterCellDef, + CdkHeaderCell, + CdkHeaderCellDef, +} from '@angular/cdk/table'; + +/** + * Cell definition for the nb-table. + * Captures the template of a column's data row cell as well as cell-specific properties. + */ +@Directive({ + selector: '[nbCellDef]', + providers: [{ provide: CdkCellDef, useExisting: NbCellDefDirective }], +}) +export class NbCellDefDirective extends CdkCellDef { +} + +/** + * Header cell definition for the nb-table. + * Captures the template of a column's header cell and as well as cell-specific properties. + */ +@Directive({ + selector: '[nbHeaderCellDef]', + providers: [{ provide: CdkHeaderCellDef, useExisting: NbHeaderCellDefDirective }], +}) +export class NbHeaderCellDefDirective extends CdkHeaderCellDef { +} + +/** + * Footer cell definition for the nb-table. + * Captures the template of a column's footer cell and as well as cell-specific properties. + */ +@Directive({ + selector: '[nbFooterCellDef]', + providers: [{ provide: CdkFooterCellDef, useExisting: NbFooterCellDefDirective }], +}) +export class NbFooterCellDefDirective extends CdkFooterCellDef { +} + +export const NB_SORT_HEADER_COLUMN_DEF = new InjectionToken('NB_SORT_HEADER_COLUMN_DEF'); + +/** + * Column definition for the nb-table. + * Defines a set of cells available for a table column. + */ +@Directive({ + selector: '[nbColumnDef]', + providers: [ + { provide: CdkColumnDef, useExisting: NbColumnDefDirective }, + { provide: NB_SORT_HEADER_COLUMN_DEF, useExisting: NbColumnDefDirective }, + ], +}) +export class NbColumnDefDirective extends CdkColumnDef { + /** Unique name for this column. */ + @Input('nbColumnDef') name: string; + + /** Whether this column should be sticky positioned at the start of the row */ + @Input() sticky: boolean; + + /** Whether this column should be sticky positioned on the end of the row */ + @Input() stickyEnd: boolean; +} + +/** Header cell template container that adds the right classes and role. */ +@Directive({ + selector: 'nb-header-cell, th[nbHeaderCell]', + host: { + 'class': 'nb-header-cell', + 'role': 'columnheader', + }, +}) +export class NbHeaderCellDirective extends CdkHeaderCell { + constructor(columnDef: NbColumnDefDirective, + elementRef: ElementRef) { + super(columnDef, elementRef); + elementRef.nativeElement.classList.add(`nb-column-${columnDef.cssClassFriendlyName}`); + } +} + +/** Footer cell template container that adds the right classes and role. */ +@Directive({ + selector: 'nb-footer-cell, td[nbFooterCell]', + host: { + 'class': 'nb-footer-cell', + 'role': 'gridcell', + }, +}) +export class NbFooterCellDirective extends CdkFooterCell { + constructor(columnDef: NbColumnDefDirective, + elementRef: ElementRef) { + super(columnDef, elementRef); + elementRef.nativeElement.classList.add(`nb-column-${columnDef.cssClassFriendlyName}`); + } +} + +/** Cell template container that adds the right classes and role. */ +@Directive({ + selector: 'nb-cell, td[nbCell]', + host: { + 'class': 'nb-cell', + 'role': 'gridcell', + }, +}) +export class NbCellDirective extends CdkCell { + constructor(columnDef: NbColumnDefDirective, + elementRef: ElementRef) { + super(columnDef, elementRef); + elementRef.nativeElement.classList.add(`nb-column-${columnDef.cssClassFriendlyName}`); + } +} diff --git a/src/framework/theme/components/cdk/table/data-source.ts b/src/framework/theme/components/cdk/table/data-source.ts new file mode 100644 index 0000000000..afaf8ffaf7 --- /dev/null +++ b/src/framework/theme/components/cdk/table/data-source.ts @@ -0,0 +1,3 @@ +import { DataSource } from '@angular/cdk/table'; + +export abstract class NbDataSource extends DataSource {} diff --git a/src/framework/theme/components/cdk/table/index.ts b/src/framework/theme/components/cdk/table/index.ts new file mode 100644 index 0000000000..1388a2d9d9 --- /dev/null +++ b/src/framework/theme/components/cdk/table/index.ts @@ -0,0 +1,5 @@ +export * from './table.module'; +export * from './cell'; +export * from './row'; +export * from './data-source'; +export * from './type-mappings'; diff --git a/src/framework/theme/components/cdk/table/row.ts b/src/framework/theme/components/cdk/table/row.ts new file mode 100644 index 0000000000..dff9610a21 --- /dev/null +++ b/src/framework/theme/components/cdk/table/row.ts @@ -0,0 +1,122 @@ +import { ChangeDetectionStrategy, Component, Directive, Input } from '@angular/core'; +import { + CdkFooterRow, + CdkFooterRowDef, + CdkHeaderRow, + CdkHeaderRowDef, + CdkRow, + CdkRowDef, + CdkCellOutlet, + DataRowOutlet, + HeaderRowOutlet, + FooterRowOutlet, +} from '@angular/cdk/table'; + +@Directive({ + selector: '[nbRowOutlet]', + providers: [{ provide: DataRowOutlet, useExisting: NbDataRowOutletDirective }], +}) +export class NbDataRowOutletDirective extends DataRowOutlet {} + +@Directive({ + selector: '[nbHeaderRowOutlet]', + providers: [{ provide: HeaderRowOutlet, useExisting: NbHeaderRowOutletDirective }], +}) +export class NbHeaderRowOutletDirective extends HeaderRowOutlet {} + +@Directive({ + selector: '[nbFooterRowOutlet]', + providers: [{ provide: FooterRowOutlet, useExisting: NbFooterRowOutletDirective }], +}) +export class NbFooterRowOutletDirective extends FooterRowOutlet {} + +@Directive({ + selector: '[nbCellOutlet]', + providers: [{ provide: CdkCellOutlet, useExisting: NbCellOutletDirective }], +}) +export class NbCellOutletDirective extends CdkCellOutlet {} + +/** + * Header row definition for the nb-table. + * Captures the header row's template and other header properties such as the columns to display. + */ +@Directive({ + selector: '[nbHeaderRowDef]', + providers: [{ provide: CdkHeaderRowDef, useExisting: NbHeaderRowDefDirective }], +}) +export class NbHeaderRowDefDirective extends CdkHeaderRowDef { + @Input('nbHeaderRowDef') columns: Iterable; + @Input('nbHeaderRowDefSticky') sticky: boolean; +} + +/** + * Footer row definition for the nb-table. + * Captures the footer row's template and other footer properties such as the columns to display. + */ +@Directive({ + selector: '[nbFooterRowDef]', + providers: [{ provide: CdkFooterRowDef, useExisting: NbFooterRowDefDirective }], +}) +export class NbFooterRowDefDirective extends CdkFooterRowDef { + @Input('nbFooterRowDef') columns: Iterable; + @Input('nbFooterRowDefSticky') sticky: boolean; +} + +/** + * Data row definition for the nb-table. + * Captures the data row's template and other properties such as the columns to display and + * a when predicate that describes when this row should be used. + */ +@Directive({ + selector: '[nbRowDef]', + providers: [{ provide: CdkRowDef, useExisting: NbRowDefDirective }], +}) +export class NbRowDefDirective extends CdkRowDef { + @Input('nbRowDefColumns') columns: Iterable; + @Input('nbRowDefWhen') when: (index: number, rowData: T) => boolean; +} + +/** Footer template container that contains the cell outlet. Adds the right class and role. */ +@Component({ + selector: 'nb-header-row, tr[nbHeaderRow]', + template: ` + `, + host: { + 'class': 'nb-header-row', + 'role': 'row', + }, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [{ provide: CdkHeaderRow, useExisting: NbHeaderRowComponent }], +}) +export class NbHeaderRowComponent extends CdkHeaderRow { +} + +/** Footer template container that contains the cell outlet. Adds the right class and role. */ +@Component({ + selector: 'nb-footer-row, tr[nbFooterRow]', + template: ` + `, + host: { + 'class': 'nb-footer-row', + 'role': 'row', + }, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [{ provide: CdkFooterRow, useExisting: NbFooterRowComponent }], +}) +export class NbFooterRowComponent extends CdkFooterRow { +} + +/** Data row template container that contains the cell outlet. Adds the right class and role. */ +@Component({ + selector: 'nb-row, tr[nbRow]', + template: ` + `, + host: { + 'class': 'nb-row', + 'role': 'row', + }, + providers: [{ provide: CdkRow, useExisting: NbRowComponent }], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NbRowComponent extends CdkRow { +} diff --git a/src/framework/theme/components/cdk/table/table.module.ts b/src/framework/theme/components/cdk/table/table.module.ts new file mode 100644 index 0000000000..5395dfc74e --- /dev/null +++ b/src/framework/theme/components/cdk/table/table.module.ts @@ -0,0 +1,81 @@ +import { Attribute, ChangeDetectorRef, ElementRef, Inject, IterableDiffers, NgModule } from '@angular/core'; +import { CdkTable, CdkTableModule } from '@angular/cdk/table'; +import { NbBidiModule, NbDirectionality } from '../bidi'; +import { NbPlatformModule, NbPlatform } from '../platform'; +import { NB_DOCUMENT } from '../../../theme.options'; +import { + NbCellDefDirective, + NbCellDirective, + NbColumnDefDirective, + NbFooterCellDefDirective, + NbFooterCellDirective, + NbHeaderCellDefDirective, + NbHeaderCellDirective, +} from './cell'; +import { + NbCellOutletDirective, + NbDataRowOutletDirective, + NbFooterRowOutletDirective, + NbHeaderRowOutletDirective, + NbFooterRowComponent, + NbFooterRowDefDirective, + NbHeaderRowComponent, + NbHeaderRowDefDirective, + NbRowComponent, + NbRowDefDirective, +} from './row'; + +export const NB_TABLE_TEMPLATE = ` + + + `; + +export class NbTable extends CdkTable { + constructor( + differs: IterableDiffers, + changeDetectorRef: ChangeDetectorRef, + elementRef: ElementRef, + @Attribute('role') role: string, + dir: NbDirectionality, + @Inject(NB_DOCUMENT) document: any, + platform: NbPlatform | undefined, + ) { + super(differs, changeDetectorRef, elementRef, role, dir, document, platform); + } +} + +const COMPONENTS = [ + NbTable, + + // Template defs + NbHeaderCellDefDirective, + NbHeaderRowDefDirective, + NbColumnDefDirective, + NbCellDefDirective, + NbRowDefDirective, + NbFooterCellDefDirective, + NbFooterRowDefDirective, + + // Outlets + NbDataRowOutletDirective, + NbHeaderRowOutletDirective, + NbFooterRowOutletDirective, + NbCellOutletDirective, + + // Cell directives + NbHeaderCellDirective, + NbCellDirective, + NbFooterCellDirective, + + // Row directives + NbHeaderRowComponent, + NbRowComponent, + NbFooterRowComponent, +]; + +@NgModule({ + imports: [ NbBidiModule, NbPlatformModule ], + declarations: [ ...COMPONENTS ], + exports: [ ...COMPONENTS ], +}) +export class NbTableModule extends CdkTableModule {} diff --git a/src/framework/theme/components/cdk/table/type-mappings.ts b/src/framework/theme/components/cdk/table/type-mappings.ts new file mode 100644 index 0000000000..854e45ee5d --- /dev/null +++ b/src/framework/theme/components/cdk/table/type-mappings.ts @@ -0,0 +1,33 @@ +import { + CdkCell, + CdkCellDef, + CdkColumnDef, + CdkFooterCell, + CdkFooterCellDef, + CdkFooterRow, + CdkFooterRowDef, + CdkHeaderCell, + CdkHeaderCellDef, + CdkHeaderRow, + CdkHeaderRowDef, + CdkRow, + CdkRowDef, +} from '@angular/cdk/table'; + +export const NbCdkRowDef = CdkRowDef; +export const NbCdkRow = CdkRow; +export const NbCdkCellDef = CdkCellDef; + +export const NbCdkHeaderRowDef = CdkHeaderRowDef; +export const NbCdkHeaderRow = CdkHeaderRow; +export const NbCdkHeaderCellDef = CdkHeaderCellDef; + +export const NbCdkFooterRowDef = CdkFooterRowDef; +export const NbCdkFooterRow = CdkFooterRow; +export const NbCdkFooterCellDef = CdkFooterCellDef; + +export const NbCdkColumnDef = CdkColumnDef; + +export const NbCdkCell = CdkCell; +export const NbCdkHeaderCell = CdkHeaderCell; +export const NbCdkFooterCell = CdkFooterCell; diff --git a/src/framework/theme/components/tree-grid/_tree-grid-sort.component.theme.scss b/src/framework/theme/components/tree-grid/_tree-grid-sort.component.theme.scss new file mode 100644 index 0000000000..8f7a080ae9 --- /dev/null +++ b/src/framework/theme/components/tree-grid/_tree-grid-sort.component.theme.scss @@ -0,0 +1,15 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +@mixin nb-tree-grid-sort-header-theme() { + .nb-tree-grid-header-change-sort-button { + background: nb-theme(tree-grid-sort-header-button-background); + border: nb-theme(tree-grid-sort-header-button-border); + padding: nb-theme(tree-grid-sort-header-button-padding); + font-weight: nb-theme(tree-grid-sort-header-button-font-weight); + color: nb-theme(tree-grid-sort-header-button-color); + } +} diff --git a/src/framework/theme/components/tree-grid/_tree-grid.component.theme.scss b/src/framework/theme/components/tree-grid/_tree-grid.component.theme.scss new file mode 100644 index 0000000000..47d4cfd8c5 --- /dev/null +++ b/src/framework/theme/components/tree-grid/_tree-grid.component.theme.scss @@ -0,0 +1,46 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +@import './tree-grid-sort.component.theme'; + +@mixin nb-tree-grid-theme() { + $border-width: nb-theme(tree-grid-cell-border-width); + $border-style: nb-theme(tree-grid-cell-border-style); + $border-color: nb-theme(tree-grid-cell-border-color); + + .nb-tree-grid-header-cell, + .nb-tree-grid-cell, + .nb-tree-grid-footer-cell { + height: nb-theme(tree-grid-row-min-height); + padding: nb-theme(tree-grid-cell-padding); + border: $border-width $border-style $border-color; + } + + .nb-tree-grid-header-row { + background: nb-theme(tree-grid-header-bg); + } + + .nb-tree-grid-footer-row { + background: nb-theme(tree-grid-footer-bg); + } + + .nb-tree-grid-row { + background: nb-theme(tree-grid-row-bg); + &:hover { + background: nb-theme(tree-grid-row-hover-bg); + } + &:nth-child(2n):not(:hover) { + background-color: nb-theme(tree-grid-row-bg-even); + } + } + + nb-tree-grid-row-toggle .row-toggle-button .icon, + nb-sort-icon .icon { + color: nb-theme(tree-grid-icon-color); + } + + @include nb-tree-grid-sort-header-theme(); +} diff --git a/src/framework/theme/components/tree-grid/data-source/tree-grid-data-source.ts b/src/framework/theme/components/tree-grid/data-source/tree-grid-data-source.ts new file mode 100644 index 0000000000..b181d05bab --- /dev/null +++ b/src/framework/theme/components/tree-grid/data-source/tree-grid-data-source.ts @@ -0,0 +1,144 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { Injectable } from '@angular/core'; +import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { NbDataSource } from '../../cdk/table'; +import { NbCollectionViewer } from '../../cdk/collections'; +import { NbTreeGridSortService } from './tree-grid-sort.service'; +import { NbTreeGridFilterService } from './tree-grid-filter.service'; +import { NbToggleOptions, NbTreeGridService } from './tree-grid.service'; +import { NbTreeGridDataService } from './tree-grid-data.service'; +import { NbSortable, NbSortRequest } from '../tree-grid-sort.component'; +import { DEFAULT_ROW_LEVEL, NbTreeGridNode, NbTreeGridPresentationNode } from './tree-grid.model'; + +export interface NbFilterable { + filter(filterRequest: string); +} + +export class NbTreeGridDataSource extends NbDataSource> + implements NbSortable, NbFilterable { + /** Stream that emits when a new data array is set on the data source. */ + private data: BehaviorSubject[]>; + + /** Stream emitting render data to the table (depends on ordered data changes). */ + private readonly renderData = new BehaviorSubject[]>([]); + + private readonly filterRequest = new BehaviorSubject(''); + + private readonly sortRequest = new BehaviorSubject(null); + + constructor(private sortService: NbTreeGridSortService, + private filterService: NbTreeGridFilterService, + private treeGridService: NbTreeGridService, + private treeGridDataService: NbTreeGridDataService) { + super(); + } + + setData(data: NbTreeGridNode[]) { + const presentationData: NbTreeGridPresentationNode[] = data + ? this.treeGridDataService.toPresentationNodes(data) + : []; + this.data = new BehaviorSubject(presentationData); + this.updateChangeSubscription(); + } + + connect( + collectionViewer: NbCollectionViewer, + ): Observable[] | ReadonlyArray>> { + return this.renderData; + } + + disconnect(collectionViewer: NbCollectionViewer) { + } + + expand(row: T) { + this.treeGridService.expand(this.data.value, row); + this.data.next(this.data.value); + } + + collapse(row: T) { + this.treeGridService.collapse(this.data.value, row); + this.data.next(this.data.value); + } + + toggle(row: T, options?: NbToggleOptions) { + this.treeGridService.toggle(this.data.value, row, options); + this.data.next(this.data.value); + } + + toggleByIndex(dataIndex: number, options?: NbToggleOptions) { + const node: NbTreeGridPresentationNode = this.renderData.value && this.renderData.value[dataIndex]; + if (node) { + this.toggle(node.data, options); + } + } + + getLevel(rowIndex: number): number { + const row = this.renderData.value[rowIndex]; + return row ? row.level : DEFAULT_ROW_LEVEL; + } + + sort(sortRequest: NbSortRequest) { + this.sortRequest.next(sortRequest); + } + + filter(searchQuery: string) { + this.filterRequest.next(searchQuery); + } + + protected updateChangeSubscription() { + const dataStream = this.data; + + const filteredData = combineLatest(dataStream, this.filterRequest) + .pipe( + map(([data]) => this.treeGridDataService.copy(data)), + map(data => this.filterData(data)), + ); + + const sortedData = combineLatest(filteredData, this.sortRequest) + .pipe( + map(([data]) => this.sortData(data)), + ); + + sortedData + .pipe( + map((data: NbTreeGridPresentationNode[]) => this.treeGridDataService.flattenExpanded(data)), + ) + .subscribe((data: NbTreeGridPresentationNode[]) => this.renderData.next(data)); + } + + private filterData(data: NbTreeGridPresentationNode[]): NbTreeGridPresentationNode[] { + return this.filterService.filter(this.filterRequest.value, data); + } + + private sortData(data: NbTreeGridPresentationNode[]): NbTreeGridPresentationNode[] { + return this.sortService.sort(this.sortRequest.value, data); + } +} + +@Injectable() +export class NbTreeGridDataSourceBuilder { + constructor(private filterService: NbTreeGridFilterService, + private sortService: NbTreeGridSortService, + private treeGridService: NbTreeGridService, + private treeGridDataService: NbTreeGridDataService) { + } + + create(data: NbTreeGridNode[]): NbTreeGridDataSource { + const dataSource = new NbTreeGridDataSource( + this.sortService, + this.filterService, + this.treeGridService, + this.treeGridDataService, + ); + + dataSource.setData(data); + return dataSource; + } +} diff --git a/src/framework/theme/components/tree-grid/data-source/tree-grid-data.service.ts b/src/framework/theme/components/tree-grid/data-source/tree-grid-data.service.ts new file mode 100644 index 0000000000..3b182a6745 --- /dev/null +++ b/src/framework/theme/components/tree-grid/data-source/tree-grid-data.service.ts @@ -0,0 +1,49 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ +import { Injectable } from '@angular/core'; + +import { DEFAULT_ROW_LEVEL, NbTreeGridNode, NbTreeGridPresentationNode } from './tree-grid.model'; + +@Injectable() +export class NbTreeGridDataService { + + toPresentationNodes(nodes: NbTreeGridNode[], level: number = DEFAULT_ROW_LEVEL): NbTreeGridPresentationNode[] { + return nodes.map((node: NbTreeGridNode) => { + const presentationNode = new NbTreeGridPresentationNode(node, level); + + if (node.children) { + presentationNode.children = this.toPresentationNodes(node.children, level + 1); + } + + return presentationNode; + }); + } + + flattenExpanded(nodes: NbTreeGridPresentationNode[]): NbTreeGridPresentationNode[] { + return nodes.reduce((res: NbTreeGridPresentationNode[], node: NbTreeGridPresentationNode) => { + res.push(node); + + if (node.expanded && node.hasChildren()) { + res.push(...this.flattenExpanded(node.children)); + } + + return res; + }, []); + } + + copy(nodes: NbTreeGridPresentationNode[]): NbTreeGridPresentationNode[] { + return nodes.map((node: NbTreeGridPresentationNode) => { + const presentationNode = new NbTreeGridPresentationNode(node.node, node.level); + presentationNode.expanded = node.expanded; + + if (node.hasChildren()) { + presentationNode.children = this.copy(node.children); + } + + return presentationNode; + }); + } +} diff --git a/src/framework/theme/components/tree-grid/data-source/tree-grid-filter.service.ts b/src/framework/theme/components/tree-grid/data-source/tree-grid-filter.service.ts new file mode 100644 index 0000000000..4320046578 --- /dev/null +++ b/src/framework/theme/components/tree-grid/data-source/tree-grid-filter.service.ts @@ -0,0 +1,52 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + + +import { Injectable } from '@angular/core'; + +import { NbTreeGridPresentationNode } from './tree-grid.model'; + +/** + * Service used to filter tree grid data. Searched searchString in all object values. + * If you need custom filter, you can extend this service and override filterPredicate or whole filter method. + */ +@Injectable() +export class NbTreeGridFilterService { + filter(query: string, data: NbTreeGridPresentationNode[]): NbTreeGridPresentationNode[] { + if (!query) { + return data; + } + + return data.reduce((filtered: NbTreeGridPresentationNode[], node: NbTreeGridPresentationNode) => { + const filteredChildren = this.filter(query, node.children); + + node.children = filteredChildren; + + node.expanded = false; + + if (filteredChildren && filteredChildren.length) { + node.expanded = true; + filtered.push(node); + } else if (this.filterPredicate(node.node.data, query)) { + filtered.push(node); + } + + return filtered; + }, []); + } + + protected filterPredicate(data: T, searchQuery: string): boolean { + const preparedQuery = searchQuery.trim().toLocaleLowerCase(); + for (const val of Object.values(data)) { + const preparedVal = `${val}`.trim().toLocaleLowerCase(); + if (preparedVal.includes(preparedQuery)) { + return true; + } + } + + return false; + } +} diff --git a/src/framework/theme/components/tree-grid/data-source/tree-grid-sort.service.ts b/src/framework/theme/components/tree-grid/data-source/tree-grid-sort.service.ts new file mode 100644 index 0000000000..7e3ea14cb0 --- /dev/null +++ b/src/framework/theme/components/tree-grid/data-source/tree-grid-sort.service.ts @@ -0,0 +1,52 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + + +import { Injectable } from '@angular/core'; +import { NbSortDirection, NbSortRequest } from '../tree-grid-sort.component'; +import { NbTreeGridPresentationNode } from './tree-grid.model'; + +/** + * Service used to sort tree grid data. Uses Array.prototype.sort method. + * If you need custom sorting, you can extend this service and override comparator or whole sort method. + */ +@Injectable() +export class NbTreeGridSortService { + + sort(request: NbSortRequest, data: NbTreeGridPresentationNode[]): NbTreeGridPresentationNode[] { + if (!request) { + return data; + } + + const sorted = data.sort((na, nb) => this.comparator(request, na, nb)); + for (const node of data) { + node.children = this.sort(request, node.children); + } + return sorted; + } + + protected comparator( + request: NbSortRequest, + na: NbTreeGridPresentationNode, + nb: NbTreeGridPresentationNode, + ): number { + const key = request.column; + const dir = request.direction; + const a = na.data[key]; + const b = nb.data[key]; + + let res = 0; + + if (a > b) { + res = 1 + } + if (a < b) { + res = -1 + } + + return dir === NbSortDirection.ASCENDING ? res : res * -1; + } +} diff --git a/src/framework/theme/components/tree-grid/data-source/tree-grid.model.ts b/src/framework/theme/components/tree-grid/data-source/tree-grid.model.ts new file mode 100644 index 0000000000..f4914a1847 --- /dev/null +++ b/src/framework/theme/components/tree-grid/data-source/tree-grid.model.ts @@ -0,0 +1,61 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +/** + * Table's data interface + */ +export interface NbTreeGridNode { + /** + * Data object which will be available as a context of rows and cell templates + * @type T + */ + data: T, + /** + * Child rows + */ + children?: NbTreeGridNode[]; + /** + * Row expand state + */ + expanded?: boolean; +} + +export const DEFAULT_ROW_LEVEL: number = 0; + +/** + * Implicit context of cells and rows + */ +export class NbTreeGridPresentationNode { + /** + * Row expand state + */ + get expanded(): boolean { + return this.node.expanded; + } + set expanded(value: boolean) { + this.node.expanded = value; + } + children: NbTreeGridPresentationNode[] = []; + + /** + * Data object associated with row + */ + get data(): T { + return this.node.data; + } + + constructor( + readonly node: NbTreeGridNode, + public readonly level: number = DEFAULT_ROW_LEVEL, + ) {} + + /** + * True if row has child rows + */ + hasChildren(): boolean { + return !!this.children && !!this.children.length; + } +} diff --git a/src/framework/theme/components/tree-grid/data-source/tree-grid.service.ts b/src/framework/theme/components/tree-grid/data-source/tree-grid.service.ts new file mode 100644 index 0000000000..59efac4051 --- /dev/null +++ b/src/framework/theme/components/tree-grid/data-source/tree-grid.service.ts @@ -0,0 +1,56 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + + +import { Injectable } from '@angular/core'; + +import { NbTreeGridPresentationNode } from './tree-grid.model'; + +export interface NbToggleOptions { + deep?: boolean; +} + +@Injectable() +export class NbTreeGridService { + expand(data: NbTreeGridPresentationNode[], row: T, options: NbToggleOptions = {}) { + const node: NbTreeGridPresentationNode = this.find(data, row); + node.expanded = true; + + if (options.deep && node.hasChildren()) { + node.children.forEach((n: NbTreeGridPresentationNode) => this.expand(data, n.data, options)); + } + } + + collapse(data: NbTreeGridPresentationNode[], row: T, options: NbToggleOptions = {}) { + const node: NbTreeGridPresentationNode = this.find(data, row); + node.expanded = false; + + if (options.deep && node.hasChildren()) { + node.children.forEach((n: NbTreeGridPresentationNode) => this.collapse(data, n.data, options)); + } + } + + toggle(data: NbTreeGridPresentationNode[], row: T, options: NbToggleOptions = {}) { + const node: NbTreeGridPresentationNode = this.find(data, row); + if (node.expanded) { + this.collapse(data, row, options); + } else { + this.expand(data, row, options); + } + } + + private find(data: NbTreeGridPresentationNode[], row: T): NbTreeGridPresentationNode { + const toCheck: NbTreeGridPresentationNode[] = [...data]; + + for (const node of toCheck) { + if (node.node.data === row) { + return node; + } + + toCheck.push(...node.children); + } + } +} diff --git a/src/framework/theme/components/tree-grid/tree-grid-cell.component.ts b/src/framework/theme/components/tree-grid/tree-grid-cell.component.ts new file mode 100644 index 0000000000..3c04bb0bef --- /dev/null +++ b/src/framework/theme/components/tree-grid/tree-grid-cell.component.ts @@ -0,0 +1,230 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { + ChangeDetectorRef, + Directive, + ElementRef, + HostBinding, + Inject, + OnInit, + OnDestroy, + PLATFORM_ID, +} from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { DomSanitizer, SafeStyle } from '@angular/platform-browser'; +import { filter, takeWhile } from 'rxjs/operators'; + +import { NbLayoutDirectionService } from '../../services/direction.service'; +import { NB_WINDOW } from '../../theme.options'; +import { + NbCdkCell, + NbCdkFooterCell, + NbCellDirective, + NbFooterCellDirective, + NbHeaderCellDirective, + NbCdkHeaderCell, +} from '../cdk/table'; +import { NB_TREE_GRID } from './tree-grid-injection-tokens'; +import { NbTreeGridComponent } from './tree-grid.component'; +import { NbTreeGridColumnDefDirective } from './tree-grid-column-def.directive'; +import { DEFAULT_ROW_LEVEL } from './data-source/tree-grid.model'; +import { NbColumnsService } from './tree-grid-columns.service'; + +@Directive({ + selector: 'td[nbTreeGridCell]', + host: { + 'class': 'nb-tree-grid-cell', + 'role': 'gridcell', + }, + providers: [{ provide: NbCdkCell, useExisting: NbTreeGridCellDirective }], +}) +export class NbTreeGridCellDirective extends NbCellDirective implements OnInit, OnDestroy { + private alive: boolean = true; + private readonly tree: NbTreeGridComponent; + private readonly columnDef: NbTreeGridColumnDefDirective; + private initialLeftPadding: string = ''; + private initialRightPadding: string = ''; + private latestWidth: string; + elementRef: ElementRef; + + @HostBinding('style.width') + get columnWidth(): string { + this.latestWidth = this.tree.getColumnWidth(); + return this.latestWidth || null; + } + + @HostBinding('style.padding-left') + get leftPadding(): string | SafeStyle | null { + if (this.directionService.isLtr()) { + return this.getStartPadding(); + } + return null; + } + + @HostBinding('style.padding-right') + get rightPadding(): string | SafeStyle | null { + if (this.directionService.isRtl()) { + return this.getStartPadding(); + } + return null; + } + + constructor( + columnDef: NbTreeGridColumnDefDirective, + elementRef: ElementRef, + @Inject(NB_TREE_GRID) tree, + @Inject(PLATFORM_ID) private platformId, + @Inject(NB_WINDOW) private window, + private sanitizer: DomSanitizer, + private directionService: NbLayoutDirectionService, + private columnService: NbColumnsService, + private cd: ChangeDetectorRef, + ) { + super(columnDef, elementRef); + this.tree = tree as NbTreeGridComponent; + this.columnDef = columnDef; + this.elementRef = elementRef; + } + + ngOnInit() { + if (isPlatformBrowser(this.platformId)) { + const style = this.window.getComputedStyle(this.elementRef.nativeElement); + this.initialLeftPadding = style.paddingLeft; + this.initialRightPadding = style.paddingRight; + } + + this.columnService.onColumnsChange() + .pipe( + takeWhile(() => this.alive), + filter(() => this.latestWidth !== this.tree.getColumnWidth()), + ) + .subscribe(() => this.cd.detectChanges()); + } + + ngOnDestroy() { + this.alive = false; + } + + toggleRow(): void { + this.tree.toggleCellRow(this); + } + + private get initialStartPadding(): string { + return this.directionService.isLtr() + ? this.initialLeftPadding + : this.initialRightPadding; + } + + private getStartPadding(): string | SafeStyle | null { + const rowLevel = this.tree.getCellLevel(this, this.columnDef.name); + if (rowLevel === DEFAULT_ROW_LEVEL) { + return null; + } + + const nestingLevel = rowLevel + 1; + let padding: string = ''; + if (this.tree.levelPadding) { + padding = `calc(${this.tree.levelPadding} * ${nestingLevel})`; + } else if (this.initialStartPadding) { + padding = `calc(${this.initialStartPadding} * ${nestingLevel})`; + } + + if (!padding) { + return null; + } + + return this.sanitizer.bypassSecurityTrustStyle(padding); + } +} + +@Directive({ + selector: 'th[nbTreeGridHeaderCell]', + host: { + 'class': 'nb-tree-grid-header-cell', + 'role': 'columnheader', + }, + providers: [{ provide: NbCdkHeaderCell, useExisting: NbTreeGridHeaderCellDirective }], +}) +export class NbTreeGridHeaderCellDirective extends NbHeaderCellDirective implements OnInit, OnDestroy { + private alive: boolean = true; + private latestWidth: string; + private readonly tree: NbTreeGridComponent; + + @HostBinding('style.width') + get columnWidth(): string { + this.latestWidth = this.tree.getColumnWidth(); + return this.latestWidth || null; + } + + constructor( + columnDef: NbTreeGridColumnDefDirective, + elementRef: ElementRef, + @Inject(NB_TREE_GRID) tree, + private columnService: NbColumnsService, + private cd: ChangeDetectorRef, + ) { + super(columnDef, elementRef); + this.tree = tree as NbTreeGridComponent; + } + + ngOnInit() { + this.columnService.onColumnsChange() + .pipe( + takeWhile(() => this.alive), + filter(() => this.latestWidth !== this.tree.getColumnWidth()), + ) + .subscribe(() => this.cd.detectChanges()); + } + + ngOnDestroy() { + this.alive = false; + } +} + +@Directive({ + selector: 'td[nbTreeGridFooterCell]', + host: { + 'class': 'nb-tree-grid-footer-cell', + 'role': 'gridcell', + }, + providers: [{ provide: NbCdkFooterCell, useExisting: NbTreeGridFooterCellDirective }], +}) +export class NbTreeGridFooterCellDirective extends NbFooterCellDirective implements OnInit, OnDestroy { + private alive: boolean = true; + private latestWidth: string; + private readonly tree: NbTreeGridComponent; + + @HostBinding('style.width') + get columnWidth(): string { + this.latestWidth = this.tree.getColumnWidth(); + return this.latestWidth || null; + } + + constructor( + columnDef: NbTreeGridColumnDefDirective, + elementRef: ElementRef, + @Inject(NB_TREE_GRID) tree, + private columnService: NbColumnsService, + private cd: ChangeDetectorRef, + ) { + super(columnDef, elementRef); + this.tree = tree as NbTreeGridComponent; + } + + ngOnInit() { + this.columnService.onColumnsChange() + .pipe( + takeWhile(() => this.alive), + filter(() => this.latestWidth !== this.tree.getColumnWidth()), + ) + .subscribe(() => this.cd.detectChanges()); + } + + ngOnDestroy() { + this.alive = false; + } +} diff --git a/src/framework/theme/components/tree-grid/tree-grid-column-def.directive.ts b/src/framework/theme/components/tree-grid/tree-grid-column-def.directive.ts new file mode 100644 index 0000000000..3268496e6f --- /dev/null +++ b/src/framework/theme/components/tree-grid/tree-grid-column-def.directive.ts @@ -0,0 +1,72 @@ +import { Directive, Input, OnChanges } from '@angular/core'; +import { NbCdkColumnDef, NB_SORT_HEADER_COLUMN_DEF, NbColumnDefDirective } from '../cdk/table'; + +/** + * Column definition for the tree-grid. + * Defines a set of cells available for a table column. + */ +@Directive({ + selector: '[nbTreeGridColumnDef]', + providers: [ + { provide: NbCdkColumnDef, useExisting: NbTreeGridColumnDefDirective }, + { provide: NB_SORT_HEADER_COLUMN_DEF, useExisting: NbTreeGridColumnDefDirective }, + ], +}) +export class NbTreeGridColumnDefDirective extends NbColumnDefDirective implements OnChanges { + /** + * Column name + */ + @Input('nbTreeGridColumnDef') name: string; + + private hideOnValue: number | null = null; + /** + * Amount of pixels of viewport at which column should be hidden. + * type number + */ + @Input() + get hideOn(): number | null { + return this.hideOnValue; + } + set hideOn(value: number | null) { + this.hideOnValue = !value && value !== 0 + ? null + : parseInt(value as unknown as string, 10); + } + + private showOnValue: number | null = null; + /** + * Amount of pixels of viewport at which column should be shown. + * type number + */ + @Input() + get showOn(): number | null { + return this.showOnValue; + } + set showOn(value: number | null) { + this.showOnValue = !value && value !== 0 + ? null + : parseInt(value as unknown as string, 10); + } + + ngOnChanges() { + if (this.hideOn != null && this.showOn != null) { + throw new Error(`hideOn and showOn are mutually exclusive and can't be used simultaneously.`); + } + } + + shouldHide(width: number): boolean { + return !this.shouldShow(width); + } + + shouldShow(width: number): boolean { + if (this.hideOn == null && this.showOn == null) { + return true; + } + + if (this.hideOn != null) { + return width > this.hideOn; + } + + return width >= this.showOn; + } +} diff --git a/src/framework/theme/components/tree-grid/tree-grid-columns.service.ts b/src/framework/theme/components/tree-grid/tree-grid-columns.service.ts new file mode 100644 index 0000000000..ed03aaad1e --- /dev/null +++ b/src/framework/theme/components/tree-grid/tree-grid-columns.service.ts @@ -0,0 +1,80 @@ +import { Injectable, IterableDiffer, IterableDiffers } from '@angular/core'; +import { merge, Observable, Subject } from 'rxjs'; + + +@Injectable() +export class NbColumnsService { + private allColumns: string[]; + private visibleColumns: string[]; + private changesDiffer: IterableDiffer; + private columnHide$: Subject = new Subject(); + private columnShow$: Subject = new Subject(); + + constructor(private differs: IterableDiffers) {} + + setColumns(columns: Iterable): void { + if (!this.changesDiffer) { + this.changesDiffer = this.differs.find(columns || []).create(); + } + + if (this.changesDiffer.diff(columns)) { + this.allColumns = Array.from(columns); + this.visibleColumns = Array.from(columns); + } + } + + getVisibleColumns(): string[] { + return this.visibleColumns; + } + + hideColumn(column: string): void { + const toRemove = this.visibleColumns.indexOf(column); + if (toRemove > -1) { + this.visibleColumns.splice(toRemove, 1); + this.columnHide$.next(); + } + } + + showColumn(column: string): void { + if (this.visibleColumns.includes(column)) { + return; + } + this.visibleColumns.splice(this.findInsertIndex(column), 0, column); + this.columnShow$.next(); + } + + onColumnsChange(): Observable { + return merge(this.columnShow$, this.columnHide$); + } + + private findInsertIndex(column: string): number { + const initialIndex = this.allColumns.indexOf(column); + + if (initialIndex === 0 || !this.visibleColumns.length) { + return 0; + } + if (initialIndex === this.allColumns.length - 1) { + return this.visibleColumns.length; + } + + const leftSiblingIndex = initialIndex - 1; + for (let i = leftSiblingIndex; i >= 0; i--) { + const leftSibling = this.allColumns[i]; + const index = this.visibleColumns.indexOf(leftSibling); + if (index !== -1) { + return index + 1; + } + } + + const rightSiblingIndex = initialIndex + 1; + for (let i = rightSiblingIndex; i < this.allColumns.length; i++) { + const rightSibling = this.allColumns[i]; + const index = this.visibleColumns.indexOf(rightSibling); + if (index !== -1) { + return index; + } + } + + throw new Error(`Can't restore column position.`); + } +} diff --git a/src/framework/theme/components/tree-grid/tree-grid-def.component.ts b/src/framework/theme/components/tree-grid/tree-grid-def.component.ts new file mode 100644 index 0000000000..c109a7462e --- /dev/null +++ b/src/framework/theme/components/tree-grid/tree-grid-def.component.ts @@ -0,0 +1,160 @@ +import { Directive, Input, IterableDiffers, TemplateRef } from '@angular/core'; +import { + NbCdkCellDef, + NbCdkFooterCellDef, + NbCdkFooterRowDef, + NbCdkHeaderCellDef, + NbCdkHeaderRowDef, + NbCdkRowDef, + NbCellDefDirective, + NbFooterCellDefDirective, + NbFooterRowDefDirective, + NbHeaderCellDefDirective, + NbHeaderRowDefDirective, + NbRowDefDirective, +} from '../cdk/table'; +import { NbColumnsService } from './tree-grid-columns.service'; + +export interface NbTreeGridResponsiveRowDef { + hideColumn(column: string); + showColumn(column: string); +} + +/** + * Data row definition for the tree-grid. + * Captures the header row's template and columns to display. + */ +@Directive({ + selector: '[nbTreeGridRowDef]', + providers: [{ provide: NbCdkRowDef, useExisting: NbTreeGridRowDefDirective }], +}) +export class NbTreeGridRowDefDirective extends NbRowDefDirective implements NbTreeGridResponsiveRowDef { + /** + * Columns to be displayed on this row + */ + @Input('nbTreeGridRowDefColumns') + set columns(value: Iterable) { + this.columnsService.setColumns(value) + } + get columns(): Iterable { + return this.columnsService.getVisibleColumns(); + } + + constructor( + template: TemplateRef, + differs: IterableDiffers, + private columnsService: NbColumnsService, + ) { + super(template, differs); + } + + /** @docs-private */ + hideColumn(column: string): void { + this.columnsService.hideColumn(column); + } + + /** @docs-private */ + showColumn(column: string): void { + this.columnsService.showColumn(column); + } +} + +@Directive({ + selector: '[nbTreeGridHeaderRowDef]', + providers: [{ provide: NbCdkHeaderRowDef, useExisting: NbTreeGridHeaderRowDefDirective }], +}) +export class NbTreeGridHeaderRowDefDirective extends NbHeaderRowDefDirective implements NbTreeGridResponsiveRowDef { + /** + * Columns to be displayed on this row + */ + @Input('nbTreeGridHeaderRowDef') + set columns(value: Iterable) { + this.columnsService.setColumns(value) + } + get columns(): Iterable { + return this.columnsService.getVisibleColumns(); + } + + constructor( + template: TemplateRef, + differs: IterableDiffers, + private columnsService: NbColumnsService, + ) { + super(template, differs); + } + + /** @docs-private */ + hideColumn(column: string): void { + this.columnsService.hideColumn(column); + } + + /** @docs-private */ + showColumn(column: string): void { + this.columnsService.showColumn(column); + } +} + +@Directive({ + selector: '[nbTreeGridFooterRowDef]', + providers: [{ provide: NbCdkFooterRowDef, useExisting: NbTreeGridFooterRowDefDirective }], +}) +export class NbTreeGridFooterRowDefDirective extends NbFooterRowDefDirective implements NbTreeGridResponsiveRowDef { + /** + * Columns to be displayed on this row + */ + @Input('nbTreeGridFooterRowDef') + set columns(value: Iterable) { + this.columnsService.setColumns(value) + } + get columns(): Iterable { + return this.columnsService.getVisibleColumns(); + } + + constructor( + template: TemplateRef, + differs: IterableDiffers, + private columnsService: NbColumnsService, + ) { + super(template, differs); + } + + /** @docs-private */ + hideColumn(column: string): void { + this.columnsService.hideColumn(column); + } + + /** @docs-private */ + showColumn(column: string): void { + this.columnsService.showColumn(column); + } +} + +/** + * Cell definition for a nb-table. + * Captures the template of a column's data row cell as well as cell-specific properties. + */ +@Directive({ + selector: '[nbTreeGridCellDef]', + providers: [{ provide: NbCdkCellDef, useExisting: NbTreeGridCellDefDirective }], +}) +export class NbTreeGridCellDefDirective extends NbCellDefDirective {} + +/** + * Header cell definition for the nb-table. + * Captures the template of a column's header cell and as well as cell-specific properties. + */ +@Directive({ + selector: '[nbTreeGridHeaderCellDef]', + providers: [{ provide: NbCdkHeaderCellDef, useExisting: NbTreeGridHeaderCellDefDirective }], +}) +export class NbTreeGridHeaderCellDefDirective extends NbHeaderCellDefDirective {} + +/** + * Footer cell definition for the nb-table. + * Captures the template of a column's footer cell and as well as cell-specific properties. + */ +@Directive({ + selector: '[nbTreeGridFooterCellDef]', + providers: [{ provide: NbCdkFooterCellDef, useExisting: NbTreeGridFooterCellDefDirective }], +}) +export class NbTreeGridFooterCellDefDirective extends NbFooterCellDefDirective {} diff --git a/src/framework/theme/components/tree-grid/tree-grid-filter.ts b/src/framework/theme/components/tree-grid/tree-grid-filter.ts new file mode 100644 index 0000000000..7f290ca9b0 --- /dev/null +++ b/src/framework/theme/components/tree-grid/tree-grid-filter.ts @@ -0,0 +1,61 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { Directive, HostListener, Input, OnDestroy, OnInit } from '@angular/core'; +import { Subject } from 'rxjs'; +import { debounceTime, takeWhile } from 'rxjs/operators'; + +import { NbFilterable } from './data-source/tree-grid-data-source'; + +@Directive({ selector: '[nbFilter]' }) +export class NbFilterDirective { + @Input('nbFilter') filterable: NbFilterable; + + filter(filterRequest: string) { + this.filterable.filter(filterRequest); + } +} + +/** + * Helper directive to trigger data source's filter method when user types in input + */ +@Directive({ + selector: '[nbFilterInput]', + providers: [{ provide: NbFilterDirective, useExisting: NbFilterInputDirective }], +}) +export class NbFilterInputDirective extends NbFilterDirective implements OnInit, OnDestroy { + private search$: Subject = new Subject(); + private alive: boolean = true; + + @Input('nbFilterInput') filterable: NbFilterable; + + /** + * Debounce time before triggering filter method. Set in milliseconds. + * Default 200. + */ + @Input() debounceTime: number = 200; + + ngOnInit() { + this.search$ + .pipe( + takeWhile(() => this.alive), + debounceTime(this.debounceTime), + ) + .subscribe((query: string) => { + super.filter(query) + }); + } + + ngOnDestroy() { + this.alive = false; + this.search$.complete(); + } + + @HostListener('input', ['$event']) + filter(event) { + this.search$.next(event.target.value); + } +} diff --git a/src/framework/theme/components/tree-grid/tree-grid-injection-tokens.ts b/src/framework/theme/components/tree-grid/tree-grid-injection-tokens.ts new file mode 100644 index 0000000000..092d199954 --- /dev/null +++ b/src/framework/theme/components/tree-grid/tree-grid-injection-tokens.ts @@ -0,0 +1,9 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { InjectionToken } from '@angular/core'; + +export const NB_TREE_GRID = new InjectionToken('NB_TREE_GRID'); diff --git a/src/framework/theme/components/tree-grid/tree-grid-row-toggle.component.ts b/src/framework/theme/components/tree-grid/tree-grid-row-toggle.component.ts new file mode 100644 index 0000000000..5776c1e455 --- /dev/null +++ b/src/framework/theme/components/tree-grid/tree-grid-row-toggle.component.ts @@ -0,0 +1,45 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { Component, HostListener, Input } from '@angular/core'; +import { NbTreeGridCellDirective } from './tree-grid-cell.component'; + +/** + * NbTreeGridRowToggleComponent + */ +@Component({ + selector: 'nb-tree-grid-row-toggle', + template: ` + + `, + styles: [` + button { + background: transparent; + border: none; + padding: 0; + } + `], +}) +export class NbTreeGridRowToggleComponent { + private expandedValue: boolean; + @Input() + set expanded(value: boolean) { + this.expandedValue = value; + } + get expanded(): boolean { + return this.expandedValue; + } + + @HostListener('click', ['$event']) + toggleRow($event: Event) { + this.cell.toggleRow(); + $event.stopPropagation(); + } + + constructor(private cell: NbTreeGridCellDirective) {} +} diff --git a/src/framework/theme/components/tree-grid/tree-grid-row-toggle.directive.ts b/src/framework/theme/components/tree-grid/tree-grid-row-toggle.directive.ts new file mode 100644 index 0000000000..02a55b3162 --- /dev/null +++ b/src/framework/theme/components/tree-grid/tree-grid-row-toggle.directive.ts @@ -0,0 +1,24 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { Directive, HostListener } from '@angular/core'; +import { NbTreeGridCellDirective } from './tree-grid-cell.component'; + +/** + * When using custom row toggle, apply this directive on your toggle to toggle row on element click. + */ +@Directive({ + selector: '[nbTreeGridRowToggle]', +}) +export class NbTreeGridRowToggleDirective { + @HostListener('click', ['$event']) + toggleRow($event: Event) { + this.cell.toggleRow(); + $event.stopPropagation(); + } + + constructor(private cell: NbTreeGridCellDirective) {} +} diff --git a/src/framework/theme/components/tree-grid/tree-grid-row.component.ts b/src/framework/theme/components/tree-grid/tree-grid-row.component.ts new file mode 100644 index 0000000000..ee5bcf9403 --- /dev/null +++ b/src/framework/theme/components/tree-grid/tree-grid-row.component.ts @@ -0,0 +1,106 @@ +import { ChangeDetectionStrategy, Component, ElementRef, HostListener, Inject, Input, OnDestroy } from '@angular/core'; +import { Subject, timer } from 'rxjs'; +import { take, takeUntil } from 'rxjs/operators'; +import { + NbCdkFooterRow, + NbCdkHeaderRow, + NbCdkRow, + NbFooterRowComponent, + NbHeaderRowComponent, + NbRowComponent, +} from '../cdk/table'; +import { NbTreeGridComponent } from './tree-grid.component'; +import { NB_TREE_GRID } from './tree-grid-injection-tokens'; + +export const NB_ROW_DOUBLE_CLICK_DELAY: number = 200; + +/** + * Cells container. Adds the right class and role. + */ +@Component({ + selector: 'tr[nbTreeGridRow]', + template: ``, + host: { + 'class': 'nb-tree-grid-row', + 'role': 'row', + }, + providers: [{ provide: NbCdkRow, useExisting: NbTreeGridRowComponent }], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NbTreeGridRowComponent extends NbRowComponent implements OnDestroy { + private readonly doubleClick$ = new Subject(); + private readonly tree: NbTreeGridComponent; + + /** + * Time to wait for second click to expand row deeply. + * 200ms by default. + */ + @Input() doubleClickDelay: number = NB_ROW_DOUBLE_CLICK_DELAY; + + /** + * Toggle row on click. Enabled by default. + */ + @Input() clickToToggle: boolean = true; + + @HostListener('click') + toggleIfEnabledNode(): void { + if (!this.clickToToggle) { + return; + } + + timer(NB_ROW_DOUBLE_CLICK_DELAY) + .pipe( + take(1), + takeUntil(this.doubleClick$), + ) + .subscribe(() => this.tree.toggleRow(this)); + } + + @HostListener('dblclick') + toggleIfEnabledNodeDeep() { + if (!this.clickToToggle) { + return; + } + + this.doubleClick$.next(); + this.tree.toggleRow(this, { deep: true }); + } + + constructor( + @Inject(NB_TREE_GRID) tree, + public elementRef: ElementRef, + ) { + super(); + this.tree = tree as NbTreeGridComponent; + } + + ngOnDestroy() { + this.doubleClick$.complete(); + } +} + +@Component({ + selector: 'tr[nbTreeGridHeaderRow]', + template: ` + `, + host: { + 'class': 'nb-tree-grid-header-row', + 'role': 'row', + }, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [{ provide: NbCdkHeaderRow, useExisting: NbTreeGridHeaderRowComponent }], +}) +export class NbTreeGridHeaderRowComponent extends NbHeaderRowComponent {} + +@Component({ + selector: 'tr[nbTreeGridFooterRow]', + template: ` + `, + host: { + 'class': 'nb-tree-grid-footer-row', + 'role': 'row', + }, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [{ provide: NbCdkFooterRow, useExisting: NbTreeGridFooterRowComponent }], +}) +export class NbTreeGridFooterRowComponent extends NbFooterRowComponent {} diff --git a/src/framework/theme/components/tree-grid/tree-grid-sort.component.ts b/src/framework/theme/components/tree-grid/tree-grid-sort.component.ts new file mode 100644 index 0000000000..7adcfa0045 --- /dev/null +++ b/src/framework/theme/components/tree-grid/tree-grid-sort.component.ts @@ -0,0 +1,203 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { + ChangeDetectionStrategy, + Component, + ContentChild, + Directive, + EventEmitter, + HostBinding, + HostListener, + Inject, + Input, + Output, + TemplateRef, +} from '@angular/core'; + +import { convertToBoolProperty } from '../helpers'; +import { NB_SORT_HEADER_COLUMN_DEF } from '../cdk/table'; + +/** Column definition associated with a `NbSortHeaderDirective`. */ +interface NbSortHeaderColumnDef { + name: string; +} + +export interface NbSortRequest { + column: string; + direction: NbSortDirection; +} + +export interface NbSortable { + sort(sortRequest: NbSortRequest); +} + +export enum NbSortDirection { + ASCENDING = 'asc', + DESCENDING = 'desc', + NONE = '', +} +const sortDirections: NbSortDirection[] = [ + NbSortDirection.ASCENDING, + NbSortDirection.DESCENDING, + NbSortDirection.NONE, +]; + +/** + * Directive triggers sort method of passed object when sort header changes direction + */ +@Directive({ selector: '[nbSort]' }) +export class NbSortDirective { + @Input('nbSort') sortable: NbSortable; + + @Output() sort: EventEmitter = new EventEmitter(); + + emitSort(sortRequest: NbSortRequest) { + if (this.sortable && this.sortable.sort) { + this.sortable.sort(sortRequest); + } + this.sort.emit(sortRequest); + } +} + +export interface NbSortHeaderIconDirectiveContext { + $implicit: NbSortDirection; + isAscending: boolean; + isDescending: boolean; + isNone: boolean; +} + +/** + * Directive for headers sort icons. Mark you icon implementation with this structural directive and + * it'll set template's implicit context with current direction. Context also has `isAscending`, + * `isDescending` and `isNone` properties. + */ +@Directive({ selector: '[nbSortHeaderIcon]' }) +export class NbSortHeaderIconDirective {} + +@Component({ + selector: 'nb-sort-icon', + template: ` + + + + `, +}) +export class NbSortIconComponent { + @Input() direction: NbSortDirection = NbSortDirection.NONE; + + isAscending(): boolean { + return this.direction === NbSortDirection.ASCENDING; + } + + isDescending(): boolean { + return this.direction === NbSortDirection.DESCENDING; + } + + isDirectionSet(): boolean { + return this.isAscending() || this.isDescending(); + } +} + +/** + * Marks header as sort header so it emitting sort event when clicked. + */ +@Component({ + selector: '[nbSortHeader]', + template: ` + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NbSortHeaderComponent { + + @ContentChild(NbSortHeaderIconDirective, { read: TemplateRef }) + sortIcon: TemplateRef; + + /** + * Current sort direction. Possible values: `asc`, `desc`, ``(none) + * @type {NbSortDirection} + */ + @Input('nbSortHeader') direction: NbSortDirection; + + private disabledValue: boolean = false; + + /** + * Disable sort header + */ + @Input() + @HostBinding('class.disabled') + set disabled(value) { + this.disabledValue = convertToBoolProperty(value); + } + get disabled(): boolean { + return this.disabledValue; + } + + @HostListener('click') + sortIfEnabled() { + if (!this.disabled) { + this.sortData(); + } + } + + constructor( + private sort: NbSortDirective, + @Inject(NB_SORT_HEADER_COLUMN_DEF) private columnDef: NbSortHeaderColumnDef, + ) {} + + isAscending(): boolean { + return this.direction === NbSortDirection.ASCENDING; + } + + isDescending(): boolean { + return this.direction === NbSortDirection.DESCENDING; + } + + sortData(): void { + const sortRequest = this.createSortRequest(); + this.sort.emitSort(sortRequest); + } + + getIconContext(): NbSortHeaderIconDirectiveContext { + return { + $implicit: this.direction, + isAscending: this.isAscending(), + isDescending: this.isDescending(), + isNone: !this.isAscending() && !this.isDescending(), + }; + } + + getDisabledAttributeValue(): '' | null { + return this.disabled ? '' : null; + } + + private createSortRequest(): NbSortRequest { + this.direction = this.getNextDirection(); + return { direction: this.direction, column: this.columnDef.name }; + } + + private getNextDirection(): NbSortDirection { + const sortDirectionCycle = sortDirections; + let nextDirectionIndex = sortDirectionCycle.indexOf(this.direction) + 1; + if (nextDirectionIndex >= sortDirectionCycle.length) { + nextDirectionIndex = 0; + } + return sortDirectionCycle[nextDirectionIndex]; + } +} diff --git a/src/framework/theme/components/tree-grid/tree-grid.component.scss b/src/framework/theme/components/tree-grid/tree-grid.component.scss new file mode 100644 index 0000000000..5163dc05bd --- /dev/null +++ b/src/framework/theme/components/tree-grid/tree-grid.component.scss @@ -0,0 +1,21 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +:host { + table-layout: fixed; + border-spacing: 0; + border-collapse: collapse; + width: 100%; + max-width: 100%; + overflow: auto; +} + + +/deep/ .nb-tree-grid-cell, +/deep/ .nb-tree-grid-header-cell, +/deep/ .nb-tree-grid-footer-cell { + overflow: hidden; +} diff --git a/src/framework/theme/components/tree-grid/tree-grid.component.spec.ts b/src/framework/theme/components/tree-grid/tree-grid.component.spec.ts new file mode 100644 index 0000000000..176cfa85f7 --- /dev/null +++ b/src/framework/theme/components/tree-grid/tree-grid.component.spec.ts @@ -0,0 +1,180 @@ +import { Component, QueryList, Type, ViewChild, ViewChildren } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { + NbThemeModule, + NbTreeGridComponent, + NbTreeGridDataSource, + NbTreeGridModule, + NbTreeGridNode, + NbTreeGridRowComponent, + NB_ROW_DOUBLE_CLICK_DELAY, +} from '@nebular/theme'; + +class BaseTreeGridTestComponent { + columns: string[]; + dataSource: NbTreeGridDataSource; + + @ViewChild(NbTreeGridComponent) treeGridComponent: NbTreeGridComponent; + @ViewChildren(NbTreeGridRowComponent) rowComponents: QueryList; +} + +@Component({ + template: ` + + + + + + + +
{{column}}{{row.data[column]}}
+ `, +}) +export class TreeGridBasicTestComponent extends BaseTreeGridTestComponent {} + +@Component({ + template: ` + + + + + + + + +
{{column}}{{row.data[column]}}
+ `, +}) +export class TreeGridWithHeaderTestComponent extends BaseTreeGridTestComponent {} + +function setupFixture( + componentType: Type, + columns: string[], + data?: NbTreeGridNode[], + ): ComponentFixture { + + TestBed.configureTestingModule({ + imports: [ NbThemeModule.forRoot(), NbTreeGridModule ], + declarations: [ componentType ], + }); + + const fixture = TestBed.createComponent(componentType); + fixture.componentInstance.columns = columns; + if (data) { + fixture.componentInstance.dataSource = data; + } + + fixture.detectChanges(); + return fixture; +} + +const abcColumns: string[] = [ 'a', 'b', 'c' ]; +const twoRowsData: NbTreeGridNode[] = [ + { data: { a: 'a1', b: 'b1', c: 'c1' } }, + { data: { a: 'a2', b: 'b2', c: 'c2' } }, +]; +const nestedRowData: NbTreeGridNode[] = [ + { + data: { a: 'a1', b: 'b1', c: 'c1' }, + children: [ { data: { a: 'a2', b: 'b2', c: 'c2' } } ], + }, +]; +const nestedExpandedRowData: NbTreeGridNode[] = [ + { + data: { a: 'a1', b: 'b1', c: 'c1' }, + expanded: true, + children: [ { data: { a: 'a2', b: 'b2', c: 'c2' } } ], + }, +]; + + +describe('NbTreeGridComponent', () => { + + it('should convert plain data to NbTreeGridDataSource', () => { + const fixture: ComponentFixture = + setupFixture(TreeGridBasicTestComponent, abcColumns, twoRowsData); + expect(fixture.componentInstance.treeGridComponent.dataSource instanceof NbTreeGridDataSource).toEqual(true); + }); + + it('should render rows', () => { + const fixture: ComponentFixture = + setupFixture(TreeGridBasicTestComponent, abcColumns, twoRowsData); + const rows: HTMLElement[] = fixture.nativeElement.querySelectorAll('.nb-tree-grid .nb-tree-grid-row'); + expect(rows.length).toEqual(twoRowsData.length); + }); + + it('should render data in row', () => { + const fixture: ComponentFixture = + setupFixture(TreeGridBasicTestComponent, abcColumns, twoRowsData); + const rows: HTMLElement[] = fixture.nativeElement.querySelectorAll('.nb-tree-grid-row'); + + rows.forEach((row: HTMLElement, rowIndex: number) => { + const dataCell = row.querySelectorAll('.nb-tree-grid-cell'); + + dataCell.forEach((cell: HTMLElement, cellIndex: number) => { + expect(cell.innerText).toEqual(twoRowsData[rowIndex].data[abcColumns[cellIndex]]); + }); + }); + }); + + it('should render header row if provided', () => { + const fixture: ComponentFixture = + setupFixture(TreeGridWithHeaderTestComponent, abcColumns, twoRowsData); + const headerRow: HTMLElement = fixture.nativeElement.querySelector('.nb-tree-grid-header-row'); + expect(headerRow).not.toBeNull(); + + const headerCells = headerRow.querySelectorAll('.nb-tree-grid-header-cell'); + expect(headerCells.length).toEqual(abcColumns.length); + + headerCells.forEach((cell: HTMLElement, cellIndex: number) => { + expect(cell.innerText).toEqual(abcColumns[cellIndex]); + }); + }); + + it('should render column text in header cell', () => { + const fixture: ComponentFixture = + setupFixture(TreeGridWithHeaderTestComponent, abcColumns, twoRowsData); + const headerRow: HTMLElement = fixture.nativeElement.querySelector('.nb-tree-grid-header-row'); + const headerCells = headerRow.querySelectorAll('.nb-tree-grid-header-cell'); + + headerCells.forEach((cell: HTMLElement, cellIndex: number) => { + expect(cell.innerText).toEqual(abcColumns[cellIndex]); + }); + }); + + it('should not render collapsed rows', () => { + const fixture: ComponentFixture = + setupFixture(TreeGridBasicTestComponent, abcColumns, nestedRowData); + const rows = fixture.nativeElement.querySelectorAll('.nb-tree-grid-row'); + expect(rows.length).toEqual(1); + }); + + it('should render initially expanded row', () => { + const fixture: ComponentFixture = + setupFixture(TreeGridBasicTestComponent, abcColumns, nestedExpandedRowData); + const rows = fixture.nativeElement.querySelectorAll('.nb-tree-grid-row'); + expect(rows.length).toEqual(2); + }); + + it('should remove collapsed rows', fakeAsync(() => { + const fixture: ComponentFixture = + setupFixture(TreeGridBasicTestComponent, abcColumns, nestedExpandedRowData); + const row: HTMLElement = fixture.nativeElement.querySelector('.nb-tree-grid-row'); + row.click(); + fixture.detectChanges(); + tick(NB_ROW_DOUBLE_CLICK_DELAY); + const rows = fixture.nativeElement.querySelectorAll('.nb-tree-grid-row'); + expect(rows.length).toEqual(1); + })); + + it('should add expanded row children', fakeAsync(() => { + const fixture: ComponentFixture = + setupFixture(TreeGridBasicTestComponent, abcColumns, nestedRowData); + const row: HTMLElement = fixture.nativeElement.querySelector('.nb-tree-grid-row'); + row.click(); + fixture.detectChanges(); + tick(NB_ROW_DOUBLE_CLICK_DELAY); + const rows = fixture.nativeElement.querySelectorAll('.nb-tree-grid-row'); + expect(rows.length).toEqual(2); + })); +}); diff --git a/src/framework/theme/components/tree-grid/tree-grid.component.ts b/src/framework/theme/components/tree-grid/tree-grid.component.ts new file mode 100644 index 0000000000..9b2841de7e --- /dev/null +++ b/src/framework/theme/components/tree-grid/tree-grid.component.ts @@ -0,0 +1,300 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { + AfterViewInit, + Attribute, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ContentChildren, + ElementRef, HostBinding, + Inject, + Input, + IterableDiffers, + OnDestroy, + QueryList, +} from '@angular/core'; +import { fromEvent, merge } from 'rxjs'; +import { debounceTime, takeWhile } from 'rxjs/operators'; + +import { NB_DOCUMENT, NB_WINDOW } from '../../theme.options'; +import { NbPlatform } from '../cdk/platform'; +import { NbDirectionality } from '../cdk/bidi'; +import { NB_TABLE_TEMPLATE, NbTable } from '../cdk/table'; +import { NbTreeGridDataSource, NbTreeGridDataSourceBuilder } from './data-source/tree-grid-data-source'; +import { DEFAULT_ROW_LEVEL, NbTreeGridNode, NbTreeGridPresentationNode } from './data-source/tree-grid.model'; +import { NbToggleOptions } from './data-source/tree-grid.service'; +import { NB_TREE_GRID } from './tree-grid-injection-tokens'; +import { NbTreeGridRowComponent } from './tree-grid-row.component'; +import { NbTreeGridCellDirective } from './tree-grid-cell.component'; +import { convertToBoolProperty } from '../helpers'; +import { NbTreeGridColumnDefDirective } from './tree-grid-column-def.directive'; +import { + NbTreeGridFooterRowDefDirective, + NbTreeGridHeaderRowDefDirective, + NbTreeGridRowDefDirective, +} from './tree-grid-def.component'; +import { NbColumnsService } from './tree-grid-columns.service'; + +/** + * Tree grid component that can be used to display nested rows of data. + * Supports filtering and sorting. + * @stacked-example(Showcase, tree-grid/tree-grid-showcase.component) + * + * Data provided to source should match [NbTreeGridNode](docs/components/treegrid/api#nbtreegridnode) interface. + * As the most basic usage you need to define [nbTreeGridRowDef](docs/components/treegrid/api#nbtreegridrowdefdirective) + * where you should pass columns to display in rows and + * [nbTreeGridColumnDef](docs/components/treegrid/api#nbtreegridcolumndefdirective) - component containing cell + * definitions for each column passed to row definition. + * @stacked-example(Basic, tree-grid/tree-grid-basic.component) + * + * To use sorting you can add `nbSort` directive to table and subscribe to `sort` method. When user click on header, + * sort event will be emitted. Event object contain clicked column name and desired sort direction. + * @stacked-example(Sortable, tree-grid/tree-grid-sortable.component) + * + * You can use `Data Source Builder` to create `NbTreeGridDataSource` which would have toggle, sort and + * filter methods. Then you can call this methods to change sort or toggle rows programmatically. Also `nbSort` and + * `nbFilterInput` directives both support `NbTreeGridDataSource`, so you can pass it directly as an input and + * directives will trigger sort, toggle themselves. + * @stacked-example(Data Source Builder, tree-grid/tree-grid-showcase.component) + * + * You can create responsive grid by setting `hideOn` and `showOn` inputs of + * [nbTreeGridColumnDef](docs/components/tree-grid/api#nbtreegridcolumndefdirective) directive. + * When viewport reaches specified width grid hides or shows columns. + * @stacked-example(Responsive columns, tree-grid/tree-grid-responsive.component) + * + * To customize sort or row toggle icons you can use `nbSortHeaderIcon` and `nbTreeGridRowToggle` directives + * respectively. `nbSortHeaderIcon` is a structural directive and it's implicit context set to current direction. + * Also context has three properties: `isAscending`, `isDescending` and `isNone`. + * @stacked-example(Custom icons, tree-grid/tree-grid-custom-icons.component) + * + * By default, row to toggle happens when user clicks anywhere in the row. Also double click expands row deeply. + * To disable this you can set `[clickToToggle]="false"` input of `nbTreeGridRow`. + * @stacked-example(Disable click toggle, tree-grid/tree-grid-disable-click-toggle.component) + * + * @styles + * + * tree-grid-cell-border-width + * tree-grid-cell-border-style + * tree-grid-cell-border-color + * tree-grid-row-min-height + * tree-grid-cell-padding + * tree-grid-sort-header-button-background + * tree-grid-sort-header-button-border + * tree-grid-sort-header-button-padding + * tree-grid-sort-header-button-font-weight + * tree-grid-header-bg + * tree-grid-footer-bg + * tree-grid-row-bg + * tree-grid-row-bg-even + * tree-grid-row-hover-bg + * tree-grid-sort-header-button-color + * tree-grid-icon-color + */ +@Component({ + selector: 'table[nbTreeGrid]', + template: NB_TABLE_TEMPLATE, + styleUrls: ['./tree-grid.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { provide: NB_TREE_GRID, useExisting: NbTreeGridComponent }, + NbColumnsService, + ], +}) +export class NbTreeGridComponent extends NbTable> + implements AfterViewInit, OnDestroy { + + constructor(private dataSourceBuilder: NbTreeGridDataSourceBuilder, + differs: IterableDiffers, + changeDetectorRef: ChangeDetectorRef, + elementRef: ElementRef, + @Attribute('role') role: string, + dir: NbDirectionality, + @Inject(NB_DOCUMENT) document: any, + platform: NbPlatform | undefined, + @Inject(NB_WINDOW) private window, + ) { + super(differs, changeDetectorRef, elementRef, role, dir, document, platform); + this.platform = platform; + } + + private alive: boolean = true; + private _source: NbTreeGridDataSource; + private platform: NbPlatform; + + /** + * The table's data + * @param data + * @type {NbTreeGridNode[] | NbTreeGridDataSource} + */ + @Input('nbTreeGrid') set source(data: NbTreeGridNode[]) { + if (!data) { + return; + } + + if (data instanceof NbTreeGridDataSource) { + this._source = data; + } else { + this._source = this.dataSourceBuilder.create(data); + } + this.dataSource = this._source; + } + + @Input() levelPadding: string = ''; + + /** + * Make all columns equal width. False by default. + */ + @Input() + set equalColumnsWidth(value: boolean) { + this.equalColumnsWidthValue = convertToBoolProperty(value); + } + get equalColumnsWidth(): boolean { + return this.equalColumnsWidthValue; + } + private equalColumnsWidthValue: boolean = false; + + @ContentChildren(NbTreeGridRowComponent) private rows: QueryList; + + @HostBinding('class.nb-tree-grid') readonly treeClass = true; + + ngAfterViewInit() { + this.checkDefsCount(); + const rowsChange$ = merge( + this._contentRowDefs.changes, + this._contentHeaderRowDefs.changes, + this._contentFooterRowDefs.changes, + ); + rowsChange$.pipe(takeWhile(() => this.alive)) + .subscribe(() => this.checkDefsCount()); + + if (this.platform.isBrowser) { + this.updateVisibleColumns(); + + const windowResize$ = fromEvent(this.window, 'resize').pipe(debounceTime(50)); + merge(rowsChange$, this._contentColumnDefs.changes, windowResize$) + .pipe(takeWhile(() => this.alive)) + .subscribe(() => this.updateVisibleColumns()); + } + } + + ngOnDestroy() { + super.ngOnDestroy(); + this.alive = false; + } + + toggleRow(row: NbTreeGridRowComponent, options?: NbToggleOptions): void { + this._source.toggleByIndex(this.getDataIndex(row), options); + } + + toggleCellRow(cell: NbTreeGridCellDirective): void { + this.toggleRow(this.findCellRow(cell)); + } + + getColumnWidth(): string { + if (this.equalColumnsWidth) { + return `${100 / this.getColumnsCount()}%`; + } + return ''; + } + + getCellLevel(cell: NbTreeGridCellDirective, columnName: string): number { + const isFirstColumn = this.isFirstColumn(columnName); + const row = isFirstColumn && this.findCellRow(cell); + const level = row && this.getRowLevel(row); + if (level || level === 0) { + return level; + } + return DEFAULT_ROW_LEVEL; + } + + private getDataIndex(row: NbTreeGridRowComponent): number { + const rowEl = row.elementRef.nativeElement; + const parent = rowEl.parentElement; + if (parent) { + return Array.from(parent.children) + .filter((child: Element) => child.hasAttribute('nbtreegridrow')) + .indexOf(rowEl); + } + + return -1; + } + + private getRowLevel(row: NbTreeGridRowComponent): number { + return this._source.getLevel(this.getDataIndex(row)); + } + + private getColumns(): string[] { + const { columns } = this._contentHeaderRowDefs.length + ? this._contentHeaderRowDefs.first + : this._contentRowDefs.first; + + return Array.from(columns || []); + } + + private getColumnsCount(): number { + return this.getColumns().length; + } + + private isFirstColumn(columnName: string): boolean { + return this.getColumns()[0] === columnName; + } + + private findCellRow(cell: NbTreeGridCellDirective): NbTreeGridRowComponent | undefined { + const cellRowElement = cell.elementRef.nativeElement.parentElement; + + return this.rows.toArray() + .find((row: NbTreeGridRowComponent) => { + return row.elementRef.nativeElement === cellRowElement; + }); + } + + private checkDefsCount(): void { + if (this._contentRowDefs.length > 1) { + throw new Error(`Found multiple row definitions`); + } + if (this._contentHeaderRowDefs.length > 1) { + throw new Error(`Found multiple header row definitions`); + } + if (this._contentFooterRowDefs.length > 1) { + throw new Error(`Found multiple footer row definitions`); + } + } + + private updateVisibleColumns(): void { + const width = this.window.innerWidth; + const columnDefs = (this._contentColumnDefs as QueryList); + + const columnsToHide: string[] = columnDefs + .filter((col: NbTreeGridColumnDefDirective) => col.shouldHide(width)) + .map(col => col.name); + + const columnsToShow: string[] = columnDefs + .filter((col: NbTreeGridColumnDefDirective) => col.shouldShow(width)) + .map(col => col.name); + + if (!columnsToHide.length && !columnsToShow.length) { + return; + } + + const rowDefs = [ + this._contentHeaderRowDefs.first as NbTreeGridHeaderRowDefDirective, + this._contentRowDefs.first as NbTreeGridRowDefDirective, + this._contentFooterRowDefs.first as NbTreeGridFooterRowDefDirective, + ].filter(d => !!d); + + for (const rowDef of rowDefs) { + for (const column of columnsToHide) { + rowDef.hideColumn(column); + } + + for (const column of columnsToShow) { + rowDef.showColumn(column); + } + } + } +} diff --git a/src/framework/theme/components/tree-grid/tree-grid.module.ts b/src/framework/theme/components/tree-grid/tree-grid.module.ts new file mode 100644 index 0000000000..bce3456b62 --- /dev/null +++ b/src/framework/theme/components/tree-grid/tree-grid.module.ts @@ -0,0 +1,93 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { NbTableModule } from '../cdk/table'; +import { NbTreeGridComponent } from './tree-grid.component'; +import { + NbTreeGridCellDefDirective, + NbTreeGridFooterCellDefDirective, + NbTreeGridFooterRowDefDirective, + NbTreeGridHeaderCellDefDirective, + NbTreeGridHeaderRowDefDirective, + NbTreeGridRowDefDirective, +} from './tree-grid-def.component'; +import { + NbTreeGridFooterRowComponent, + NbTreeGridHeaderRowComponent, + NbTreeGridRowComponent, +} from './tree-grid-row.component'; +import { + NbTreeGridCellDirective, + NbTreeGridFooterCellDirective, + NbTreeGridHeaderCellDirective, +} from './tree-grid-cell.component'; +import { + NbSortDirective, + NbSortHeaderComponent, + NbSortHeaderIconDirective, + NbSortIconComponent, +} from './tree-grid-sort.component'; +import { NbTreeGridDataSourceBuilder } from './data-source/tree-grid-data-source'; +import { NbTreeGridSortService } from './data-source/tree-grid-sort.service'; +import { NbTreeGridFilterService } from './data-source/tree-grid-filter.service'; +import { NbTreeGridService } from './data-source/tree-grid.service'; +import { NbTreeGridDataService } from './data-source/tree-grid-data.service'; +import { NbFilterDirective, NbFilterInputDirective } from './tree-grid-filter'; +import { NbTreeGridRowToggleDirective } from './tree-grid-row-toggle.directive'; +import { NbTreeGridColumnDefDirective } from './tree-grid-column-def.directive'; +import { NbTreeGridRowToggleComponent } from './tree-grid-row-toggle.component'; + +const COMPONENTS = [ + // Tree Grid + NbTreeGridComponent, + + NbTreeGridRowDefDirective, + NbTreeGridRowComponent, + NbTreeGridCellDefDirective, + NbTreeGridCellDirective, + + NbTreeGridHeaderRowDefDirective, + NbTreeGridHeaderRowComponent, + NbTreeGridHeaderCellDefDirective, + NbTreeGridHeaderCellDirective, + + NbTreeGridFooterRowDefDirective, + NbTreeGridFooterRowComponent, + NbTreeGridFooterCellDefDirective, + NbTreeGridFooterCellDirective, + + NbTreeGridColumnDefDirective, + + // Sort directives + NbSortDirective, + NbSortHeaderComponent, + NbSortIconComponent, + + // Filter directives + NbFilterDirective, + NbFilterInputDirective, + + NbTreeGridRowToggleDirective, + NbTreeGridRowToggleComponent, + NbSortHeaderIconDirective, +]; + +@NgModule({ + imports: [ CommonModule, NbTableModule ], + declarations: [ ...COMPONENTS ], + exports: [ NbTableModule, ...COMPONENTS ], + providers: [ + NbTreeGridSortService, + NbTreeGridFilterService, + NbTreeGridService, + NbTreeGridDataService, + NbTreeGridDataSourceBuilder, + ], +}) +export class NbTreeGridModule {} diff --git a/src/framework/theme/index.ts b/src/framework/theme/index.ts index e9eaaa52b8..7f54b80c6b 100644 --- a/src/framework/theme/index.ts +++ b/src/framework/theme/index.ts @@ -82,3 +82,20 @@ export * from './components/window'; export * from './components/datepicker/datepicker.module'; export * from './components/datepicker/datepicker.directive'; export * from './components/radio/radio.module'; +export * from './components/tree-grid/tree-grid.module'; +export * from './components/tree-grid/tree-grid.component'; +export * from './components/tree-grid/tree-grid-row.component'; +export * from './components/tree-grid/tree-grid-injection-tokens'; +export * from './components/tree-grid/tree-grid-sort.component'; +export * from './components/tree-grid/tree-grid-row-toggle.component'; +export * from './components/tree-grid/tree-grid-column-def.directive'; +export * from './components/tree-grid/tree-grid-cell.component'; +export * from './components/tree-grid/tree-grid-def.component'; +export * from './components/tree-grid/tree-grid-filter'; +export * from './components/tree-grid/tree-grid-row-toggle.directive'; +export * from './components/tree-grid/data-source/tree-grid.model'; +export * from './components/tree-grid/data-source/tree-grid-data-source'; +export * from './components/tree-grid/data-source/tree-grid-data.service'; +export * from './components/tree-grid/data-source/tree-grid-filter.service'; +export * from './components/tree-grid/data-source/tree-grid.service'; +export * from './components/tree-grid/data-source/tree-grid-sort.service'; diff --git a/src/framework/theme/styles/global/_components.scss b/src/framework/theme/styles/global/_components.scss index c4530a2caa..77fdd6cdf1 100644 --- a/src/framework/theme/styles/global/_components.scss +++ b/src/framework/theme/styles/global/_components.scss @@ -37,6 +37,7 @@ @import '../../components/window/window.component.theme'; @import '../../components/datepicker/datepicker-container.component.theme'; @import '../../components/radio/radio.component.theme'; +@import '../../components/tree-grid/tree-grid.component.theme'; @mixin nb-theme-components() { @@ -73,4 +74,5 @@ @include nb-window-theme(); @include nb-datepicker-theme(); @include nb-radio-theme(); + @include nb-tree-grid-theme(); } diff --git a/src/framework/theme/styles/themes/_default.scss b/src/framework/theme/styles/themes/_default.scss index 4678a4d95a..2fe6c8636c 100644 --- a/src/framework/theme/styles/themes/_default.scss +++ b/src/framework/theme/styles/themes/_default.scss @@ -679,6 +679,23 @@ $theme: ( radio-disabled-border-size: 2px, radio-disabled-border-color: radio-border-color, radio-disabled-checkmark: radio-checkmark, + + tree-grid-cell-border-width: 1px, + tree-grid-cell-border-style: solid, + tree-grid-cell-border-color: separator, + tree-grid-row-min-height: 2rem, + tree-grid-cell-padding: 0.875rem 1.25rem, + tree-grid-sort-header-button-background: transparent, + tree-grid-sort-header-button-border: none, + tree-grid-sort-header-button-padding: 0, + tree-grid-sort-header-button-font-weight: bold, + tree-grid-header-bg: color-bg, + tree-grid-footer-bg: color-bg, + tree-grid-row-bg: color-bg, + tree-grid-row-bg-even: color-bg, + tree-grid-row-hover-bg: color-bg, + tree-grid-sort-header-button-color: color-fg-text, + tree-grid-icon-color: color-fg-text, ); // register the theme diff --git a/src/playground/with-layout/tree-grid/components/fs-icon.component.ts b/src/playground/with-layout/tree-grid/components/fs-icon.component.ts new file mode 100644 index 0000000000..662ae9ec9c --- /dev/null +++ b/src/playground/with-layout/tree-grid/components/fs-icon.component.ts @@ -0,0 +1,20 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'nb-fs-icon', + template: ` + + + + + + `, +}) +export class FsIconComponent { + @Input() kind: string; + @Input() expanded: boolean; + + isDir(): boolean { + return this.kind === 'dir'; + } +} diff --git a/src/playground/with-layout/tree-grid/tree-grid-basic.component.ts b/src/playground/with-layout/tree-grid/tree-grid-basic.component.ts new file mode 100644 index 0000000000..f8c9ad67c3 --- /dev/null +++ b/src/playground/with-layout/tree-grid/tree-grid-basic.component.ts @@ -0,0 +1,86 @@ +import { Component } from '@angular/core'; +import { NbTreeGridNode } from '@nebular/theme'; + +interface FSEntry { + name: string; + size: string; + kind: string; + items?: number; +} + +@Component({ + template: ` + + + + + + + + + + + + + + + +
+ + {{row.data.name}} + {{row.data[column]}}
+ +
+
+ `, + styleUrls: ['./tree-grid-shared.scss'], +}) +export class TreeGridBasicComponent { + customColumn = 'name'; + defaultColumns = [ 'size', 'kind', 'items' ]; + allColumns = [ this.customColumn, ...this.defaultColumns ]; + + data: NbTreeGridNode[] = [ + { + data: { name: 'Projects', size: '1.8 MB', items: 5, kind: 'dir' }, + children: [ + { data: { name: 'project-1.doc', kind: 'doc', size: '240 KB' } }, + { data: { name: 'project-2.doc', kind: 'doc', size: '290 KB' } }, + { + data: { name: 'project-3', kind: 'dir', size: '466 KB', items: 3 }, + children: [ + { data: { name: 'project-3A.doc', kind: 'doc', size: '200 KB' } }, + { data: { name: 'project-3B.doc', kind: 'doc', size: '266 KB' } }, + { data: { name: 'project-3C.doc', kind: 'doc', size: '0' } }, + ], + }, + { data: { name: 'project-4.docx', kind: 'docx', size: '900 KB' } }, + ], + }, + { + data: { name: 'Reports', kind: 'dir', size: '400 KB', items: 2 }, + children: [ + { + data: { name: 'Report 1', kind: 'dir', size: '100 KB', items: 1 }, + children: [ + { data: { name: 'report-1.doc', kind: 'doc', size: '100 KB' } }, + ], + }, + { + data: { name: 'Report 2', kind: 'dir', size: '300 KB', items: 2 }, + children: [ + { data: { name: 'report-2.doc', kind: 'doc', size: '290 KB' } }, + { data: { name: 'report-2-note.txt', kind: 'txt', size: '10 KB' } }, + ], + }, + ], + }, + { + data: { name: 'Other', kind: 'dir', size: '109 MB', items: 2 }, + children: [ + { data: { name: 'backup.bkp', kind: 'bkp', size: '107 MB' } }, + { data: { name: 'secret-note.txt', kind: 'txt', size: '2 MB' } }, + ], + }, + ]; +} diff --git a/src/playground/with-layout/tree-grid/tree-grid-custom-icons.component.scss b/src/playground/with-layout/tree-grid/tree-grid-custom-icons.component.scss new file mode 100644 index 0000000000..0fd6dc5570 --- /dev/null +++ b/src/playground/with-layout/tree-grid/tree-grid-custom-icons.component.scss @@ -0,0 +1,5 @@ +button { + background: transparent; + border: none; + padding: 0; +} diff --git a/src/playground/with-layout/tree-grid/tree-grid-custom-icons.component.ts b/src/playground/with-layout/tree-grid/tree-grid-custom-icons.component.ts new file mode 100644 index 0000000000..a5267d1dc1 --- /dev/null +++ b/src/playground/with-layout/tree-grid/tree-grid-custom-icons.component.ts @@ -0,0 +1,110 @@ +import { Component } from '@angular/core'; +import { NbTreeGridDataSource, NbTreeGridDataSourceBuilder, NbTreeGridNode } from '@nebular/theme'; + +interface FSEntry { + name: string; + size: string; + kind: string; + items?: number; +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + + + + +
+ {{customColumn}} + + + + + + + {{row.data.name}} + {{column}}{{row.data[column]}}
+ +
+
+ `, + styleUrls: ['./tree-grid-shared.scss', './tree-grid-custom-icons.component.scss'], +}) +export class TreeGridCustomIconsComponent { + customColumn = 'name'; + defaultColumns = [ 'size', 'kind', 'items' ]; + allColumns = [ this.customColumn, ...this.defaultColumns ]; + + dataSource: NbTreeGridDataSource; + + constructor(dataSourceBuilder: NbTreeGridDataSourceBuilder) { + this.dataSource = dataSourceBuilder.create(this.data); + } + + private data: NbTreeGridNode[] = [ + { + data: { name: 'Projects', size: '1.8 MB', items: 5, kind: 'dir' }, + children: [ + { data: { name: 'project-1.doc', kind: 'doc', size: '240 KB' } }, + { data: { name: 'project-2.doc', kind: 'doc', size: '290 KB' } }, + { + data: { name: 'project-3', kind: 'dir', size: '466 KB', items: 3 }, + children: [ + { data: { name: 'project-3A.doc', kind: 'doc', size: '200 KB' } }, + { data: { name: 'project-3B.doc', kind: 'doc', size: '266 KB' } }, + { data: { name: 'project-3C.doc', kind: 'doc', size: '0' } }, + ], + }, + { data: { name: 'project-4.docx', kind: 'docx', size: '900 KB' } }, + ], + }, + { + data: { name: 'Reports', kind: 'dir', size: '400 KB', items: 2 }, + children: [ + { + data: { name: 'Report 1', kind: 'dir', size: '100 KB', items: 1 }, + children: [ + { data: { name: 'report-1.doc', kind: 'doc', size: '100 KB' } }, + ], + }, + { + data: { name: 'Report 2', kind: 'dir', size: '300 KB', items: 2 }, + children: [ + { data: { name: 'report-2.doc', kind: 'doc', size: '290 KB' } }, + { data: { name: 'report-2-note.txt', kind: 'txt', size: '10 KB' } }, + ], + }, + ], + }, + { + data: { name: 'Other', kind: 'dir', size: '109 MB', items: 2 }, + children: [ + { data: { name: 'backup.bkp', kind: 'bkp', size: '107 MB' } }, + { data: { name: 'secret-note.txt', kind: 'txt', size: '2 MB' } }, + ], + }, + ]; +} diff --git a/src/playground/with-layout/tree-grid/tree-grid-disable-click-toggle.component.ts b/src/playground/with-layout/tree-grid/tree-grid-disable-click-toggle.component.ts new file mode 100644 index 0000000000..2090e54369 --- /dev/null +++ b/src/playground/with-layout/tree-grid/tree-grid-disable-click-toggle.component.ts @@ -0,0 +1,86 @@ +import { Component } from '@angular/core'; +import { NbTreeGridNode } from '@nebular/theme'; + +interface FSEntry { + name: string; + size: string; + kind: string; + items?: number; +} + +@Component({ + template: ` + + + + + + + + + + + + + + + +
+ + {{row.data.name}} + {{row.data[column]}}
+ +
+
+ `, + styleUrls: ['./tree-grid-shared.scss'], +}) +export class TreeGridDisableClickToggleComponent { + customColumn = 'name'; + defaultColumns = [ 'size', 'kind', 'items' ]; + allColumns = [ this.customColumn, ...this.defaultColumns ]; + + data: NbTreeGridNode[] = [ + { + data: { name: 'Projects', size: '1.8 MB', items: 5, kind: 'dir' }, + children: [ + { data: { name: 'project-1.doc', kind: 'doc', size: '240 KB' } }, + { data: { name: 'project-2.doc', kind: 'doc', size: '290 KB' } }, + { + data: { name: 'project-3', kind: 'dir', size: '466 KB', items: 3 }, + children: [ + { data: { name: 'project-3A.doc', kind: 'doc', size: '200 KB' } }, + { data: { name: 'project-3B.doc', kind: 'doc', size: '266 KB' } }, + { data: { name: 'project-3C.doc', kind: 'doc', size: '0' } }, + ], + }, + { data: { name: 'project-4.docx', kind: 'docx', size: '900 KB' } }, + ], + }, + { + data: { name: 'Reports', kind: 'dir', size: '400 KB', items: 2 }, + children: [ + { + data: { name: 'Report 1', kind: 'dir', size: '100 KB', items: 1 }, + children: [ + { data: { name: 'report-1.doc', kind: 'doc', size: '100 KB' } }, + ], + }, + { + data: { name: 'Report 2', kind: 'dir', size: '300 KB', items: 2 }, + children: [ + { data: { name: 'report-2.doc', kind: 'doc', size: '290 KB' } }, + { data: { name: 'report-2-note.txt', kind: 'txt', size: '10 KB' } }, + ], + }, + ], + }, + { + data: { name: 'Other', kind: 'dir', size: '109 MB', items: 2 }, + children: [ + { data: { name: 'backup.bkp', kind: 'bkp', size: '107 MB' } }, + { data: { name: 'secret-note.txt', kind: 'txt', size: '2 MB' } }, + ], + }, + ]; +} diff --git a/src/playground/with-layout/tree-grid/tree-grid-filterable.component.ts b/src/playground/with-layout/tree-grid/tree-grid-filterable.component.ts new file mode 100644 index 0000000000..5b36740eb5 --- /dev/null +++ b/src/playground/with-layout/tree-grid/tree-grid-filterable.component.ts @@ -0,0 +1,105 @@ +import { Component } from '@angular/core'; +import { NbTreeGridDataSource, NbTreeGridDataSourceBuilder, NbTreeGridNode } from '@nebular/theme'; + +interface FSEntry { + name: string; + size: string; + kind: string; + items?: number; +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + + + + + +
+ {{customColumn}} + + + {{row.data.name}} + {{column}}{{row.data[column]}}
+ +
+
+ `, + styleUrls: ['./tree-grid-shared.scss', './tree-grid-showcase.component.scss'], +}) +export class TreeGridFilterableComponent { + customColumn = 'name'; + defaultColumns = [ 'size', 'kind', 'items' ]; + allColumns = [ this.customColumn, ...this.defaultColumns ]; + + dataSource: NbTreeGridDataSource; + + constructor(private dataSourceBuilder: NbTreeGridDataSourceBuilder) { + this.dataSource = this.dataSourceBuilder.create(this.data); + } + + filter(event) { + this.dataSource.filter(event.target.value); + } + + private data: NbTreeGridNode[] = [ + { + data: { name: 'Projects', size: '1.8 MB', items: 5, kind: 'dir' }, + children: [ + { data: { name: 'project-1.doc', kind: 'doc', size: '240 KB' } }, + { data: { name: 'project-2.doc', kind: 'doc', size: '290 KB' } }, + { + data: { name: 'project-3', kind: 'dir', size: '466 KB', items: 3 }, + children: [ + { data: { name: 'project-3A.doc', kind: 'doc', size: '200 KB' } }, + { data: { name: 'project-3B.doc', kind: 'doc', size: '266 KB' } }, + { data: { name: 'project-3C.doc', kind: 'doc', size: '0' } }, + ], + }, + { data: { name: 'project-4.docx', kind: 'docx', size: '900 KB' } }, + ], + }, + { + data: { name: 'Reports', kind: 'dir', size: '400 KB', items: 2 }, + children: [ + { + data: { name: 'Report 1', kind: 'dir', size: '100 KB', items: 1 }, + children: [ + { data: { name: 'report-1.doc', kind: 'doc', size: '100 KB' } }, + ], + }, + { + data: { name: 'Report 2', kind: 'dir', size: '300 KB', items: 2 }, + children: [ + { data: { name: 'report-2.doc', kind: 'doc', size: '290 KB' } }, + { data: { name: 'report-2-note.txt', kind: 'txt', size: '10 KB' } }, + ], + }, + ], + }, + { + data: { name: 'Other', kind: 'dir', size: '109 MB', items: 2 }, + children: [ + { data: { name: 'backup.bkp', kind: 'bkp', size: '107 MB' } }, + { data: { name: 'secret-note.txt', kind: 'txt', size: '2 MB' } }, + ], + }, + ]; +} diff --git a/src/playground/with-layout/tree-grid/tree-grid-responsive.component.ts b/src/playground/with-layout/tree-grid/tree-grid-responsive.component.ts new file mode 100644 index 0000000000..485ab17540 --- /dev/null +++ b/src/playground/with-layout/tree-grid/tree-grid-responsive.component.ts @@ -0,0 +1,96 @@ +import { Component } from '@angular/core'; +import { NbTreeGridNode } from '@nebular/theme'; + +interface FSEntry { + name: string; + size: string; + kind: string; + items?: number; +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + +
name + + {{row.data.name}} + size{{row.data.size}}kind{{row.data.kind}}items{{row.data.items || '-'}}
+ +
+
+ `, + styleUrls: ['./tree-grid-shared.scss'], +}) +export class TreeGridResponsiveComponent { + allColumns = [ 'name', 'size', 'kind', 'items' ]; + + data: NbTreeGridNode[] = [ + { + data: { name: 'Projects', size: '1.8 MB', items: 5, kind: 'dir' }, + children: [ + { data: { name: 'project-1.doc', kind: 'doc', size: '240 KB' } }, + { data: { name: 'project-2.doc', kind: 'doc', size: '290 KB' } }, + { + data: { name: 'project-3', kind: 'dir', size: '466 KB', items: 3 }, + children: [ + { data: { name: 'project-3A.doc', kind: 'doc', size: '200 KB' } }, + { data: { name: 'project-3B.doc', kind: 'doc', size: '266 KB' } }, + { data: { name: 'project-3C.doc', kind: 'doc', size: '0' } }, + ], + }, + { data: { name: 'project-4.docx', kind: 'docx', size: '900 KB' } }, + ], + }, + { + data: { name: 'Reports', kind: 'dir', size: '400 KB', items: 2 }, + children: [ + { + data: { name: 'Report 1', kind: 'dir', size: '100 KB', items: 1 }, + children: [ + { data: { name: 'report-1.doc', kind: 'doc', size: '100 KB' } }, + ], + }, + { + data: { name: 'Report 2', kind: 'dir', size: '300 KB', items: 2 }, + children: [ + { data: { name: 'report-2.doc', kind: 'doc', size: '290 KB' } }, + { data: { name: 'report-2-note.txt', kind: 'txt', size: '10 KB' } }, + ], + }, + ], + }, + { + data: { name: 'Other', kind: 'dir', size: '109 MB', items: 2 }, + children: [ + { data: { name: 'backup.bkp', kind: 'bkp', size: '107 MB' } }, + { data: { name: 'secret-note.txt', kind: 'txt', size: '2 MB' } }, + ], + }, + ]; +} diff --git a/src/playground/with-layout/tree-grid/tree-grid-routing.module.ts b/src/playground/with-layout/tree-grid/tree-grid-routing.module.ts new file mode 100644 index 0000000000..36bdac73cd --- /dev/null +++ b/src/playground/with-layout/tree-grid/tree-grid-routing.module.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { NgModule } from '@angular/core'; +import { RouterModule, Route} from '@angular/router'; +import { TreeGridShowcaseComponent } from './tree-grid-showcase.component'; +import { TreeGridSortableComponent } from './tree-grid-sortable.component'; +import { TreeGridFilterableComponent } from './tree-grid-filterable.component'; +import { TreeGridBasicComponent } from './tree-grid-basic.component'; +import { TreeGridResponsiveComponent } from './tree-grid-responsive.component'; +import { TreeGridCustomIconsComponent } from './tree-grid-custom-icons.component'; +import { TreeGridDisableClickToggleComponent } from './tree-grid-disable-click-toggle.component'; + +const routes: Route[] = [ + { + path: 'tree-grid-showcase.component', + component: TreeGridShowcaseComponent, + }, + { + path: 'tree-grid-sortable.component', + component: TreeGridSortableComponent, + }, + { + path: 'tree-grid-filterable.component', + component: TreeGridFilterableComponent, + }, + { + path: 'tree-grid-basic.component', + component: TreeGridBasicComponent, + }, + { + path: 'tree-grid-responsive.component', + component: TreeGridResponsiveComponent, + }, + { + path: 'tree-grid-custom-icons.component', + component: TreeGridCustomIconsComponent, + }, + { + path: 'tree-grid-disable-click-toggle.component', + component: TreeGridDisableClickToggleComponent, + }, +]; + +@NgModule({ + imports: [ RouterModule.forChild(routes) ], + exports: [ RouterModule ], +}) +export class TreeGridRoutingModule {} diff --git a/src/playground/with-layout/tree-grid/tree-grid-shared.scss b/src/playground/with-layout/tree-grid/tree-grid-shared.scss new file mode 100644 index 0000000000..11e64a866c --- /dev/null +++ b/src/playground/with-layout/tree-grid/tree-grid-shared.scss @@ -0,0 +1,10 @@ +/deep/ { + body { + min-height: 20rem; + } + + .nb-tree-grid-header-cell, + .nb-tree-grid-header-cell button { + text-transform: capitalize; + } +} diff --git a/src/playground/with-layout/tree-grid/tree-grid-showcase.component.html b/src/playground/with-layout/tree-grid/tree-grid-showcase.component.html new file mode 100644 index 0000000000..e67e1e4093 --- /dev/null +++ b/src/playground/with-layout/tree-grid/tree-grid-showcase.component.html @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + +
+ {{customColumn}} + + + {{row.data[customColumn]}} + + {{column}} + {{row.data[column] || '-'}}
+ +
+
diff --git a/src/playground/with-layout/tree-grid/tree-grid-showcase.component.scss b/src/playground/with-layout/tree-grid/tree-grid-showcase.component.scss new file mode 100644 index 0000000000..b92d0d37d2 --- /dev/null +++ b/src/playground/with-layout/tree-grid/tree-grid-showcase.component.scss @@ -0,0 +1,42 @@ +button[nbTreeGridRowToggle] { + background: transparent; + border: none; + padding: 0; +} + +.search-label { + display: block; +} +.search-input { + margin-bottom: 1rem; +} + +.nb-column-name { + width: 100%; +} + +@media screen and (min-width: 400px) { + .nb-column-name, + .nb-column-size { + width: 50%; + } +} + +@media screen and (min-width: 500px) { + .nb-column-name, + .nb-column-size, + .nb-column-kind { + width: 33.333%; + } +} + +@media screen and (min-width: 600px) { + .nb-column-name { + width: 31%; + } + .nb-column-size, + .nb-column-kind, + .nb-column-items { + width: 23%; + } +} diff --git a/src/playground/with-layout/tree-grid/tree-grid-showcase.component.ts b/src/playground/with-layout/tree-grid/tree-grid-showcase.component.ts new file mode 100644 index 0000000000..16e04db5bd --- /dev/null +++ b/src/playground/with-layout/tree-grid/tree-grid-showcase.component.ts @@ -0,0 +1,103 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { Component } from '@angular/core'; +import { + NbSortDirection, + NbSortRequest, + NbTreeGridDataSource, + NbTreeGridDataSourceBuilder, + NbTreeGridNode, +} from '@nebular/theme'; + +interface FSEntry { + name: string; + size: string; + kind: string; + items?: number; +} + +@Component({ + selector: 'nb-tree-grid-showcase', + templateUrl: './tree-grid-showcase.component.html', + styleUrls: ['./tree-grid-shared.scss', './tree-grid-showcase.component.scss'], +}) +export class TreeGridShowcaseComponent { + customColumn = 'name'; + defaultColumns = [ 'size', 'kind', 'items' ]; + allColumns = [ this.customColumn, ...this.defaultColumns ]; + + dataSource: NbTreeGridDataSource; + + sortColumn: string; + sortDirection: NbSortDirection = NbSortDirection.NONE; + + constructor(private dataSourceBuilder: NbTreeGridDataSourceBuilder) { + this.dataSource = this.dataSourceBuilder.create(this.data); + } + + updateSort(sortRequest: NbSortRequest): void { + this.sortColumn = sortRequest.column; + this.sortDirection = sortRequest.direction; + } + + getSortDirection(column: string): NbSortDirection { + if (this.sortColumn === column) { + return this.sortDirection; + } + return NbSortDirection.NONE; + } + + private data: NbTreeGridNode[] = [ + { + data: { name: 'Projects', size: '1.8 MB', items: 5, kind: 'dir' }, + children: [ + { data: { name: 'project-1.doc', kind: 'doc', size: '240 KB' } }, + { data: { name: 'project-2.doc', kind: 'doc', size: '290 KB' } }, + { + data: { name: 'project-3', kind: 'dir', size: '466 KB', items: 3 }, + children: [ + { data: { name: 'project-3A.doc', kind: 'doc', size: '200 KB' } }, + { data: { name: 'project-3B.doc', kind: 'doc', size: '266 KB' } }, + { data: { name: 'project-3C.doc', kind: 'doc', size: '0' } }, + ], + }, + { data: { name: 'project-4.docx', kind: 'docx', size: '900 KB' } }, + ], + }, + { + data: { name: 'Reports', kind: 'dir', size: '400 KB', items: 2 }, + children: [ + { + data: { name: 'Report 1', kind: 'dir', size: '100 KB', items: 1 }, + children: [ + { data: { name: 'report-1.doc', kind: 'doc', size: '100 KB' } }, + ], + }, + { + data: { name: 'Report 2', kind: 'dir', size: '300 KB', items: 2 }, + children: [ + { data: { name: 'report-2.doc', kind: 'doc', size: '290 KB' } }, + { data: { name: 'report-2-note.txt', kind: 'txt', size: '10 KB' } }, + ], + }, + ], + }, + { + data: { name: 'Other', kind: 'dir', size: '109 MB', items: 2 }, + children: [ + { data: { name: 'backup.bkp', kind: 'bkp', size: '107 MB' } }, + { data: { name: 'secret-note.txt', kind: 'txt', size: '2 MB' } }, + ], + }, + ]; + + getShowOn(index: number) { + const minWithForMultipleColumns = 400; + const nextColumnStep = 100; + return minWithForMultipleColumns + (nextColumnStep * index); + } +} diff --git a/src/playground/with-layout/tree-grid/tree-grid-sortable.component.ts b/src/playground/with-layout/tree-grid/tree-grid-sortable.component.ts new file mode 100644 index 0000000000..d52dcf75bc --- /dev/null +++ b/src/playground/with-layout/tree-grid/tree-grid-sortable.component.ts @@ -0,0 +1,123 @@ +import { Component } from '@angular/core'; +import { + NbSortDirection, + NbSortRequest, + NbTreeGridDataSource, + NbTreeGridDataSourceBuilder, + NbTreeGridNode, +} from '@nebular/theme'; + +interface FSEntry { + name: string; + size: string; + kind: string; + items?: number; +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + + + +
+ {{customColumn}} + + + {{row.data.name}} + + {{column}} + {{row.data[column]}}
+ +
+
+ `, + styleUrls: ['./tree-grid-shared.scss'], +}) +export class TreeGridSortableComponent { + customColumn = 'name'; + defaultColumns = [ 'size', 'kind', 'items' ]; + allColumns = [ this.customColumn, ...this.defaultColumns ]; + + dataSource: NbTreeGridDataSource; + + sortColumn: string = ''; + sortDirection: NbSortDirection = NbSortDirection.NONE; + + constructor(private dataSourceBuilder: NbTreeGridDataSourceBuilder) { + this.dataSource = this.dataSourceBuilder.create(this.data); + } + + changeSort(sortRequest: NbSortRequest): void { + this.dataSource.sort(sortRequest); + this.sortColumn = sortRequest.column; + this.sortDirection = sortRequest.direction; + } + + getDirection(column: string): NbSortDirection { + if (column === this.sortColumn) { + return this.sortDirection; + } + return NbSortDirection.NONE; + } + + private data: NbTreeGridNode[] = [ + { + data: { name: 'Projects', size: '1.8 MB', items: 5, kind: 'dir' }, + children: [ + { data: { name: 'project-1.doc', kind: 'doc', size: '240 KB' } }, + { data: { name: 'project-2.doc', kind: 'doc', size: '290 KB' } }, + { + data: { name: 'project-3', kind: 'dir', size: '466 KB', items: 3 }, + children: [ + { data: { name: 'project-3A.doc', kind: 'doc', size: '200 KB' } }, + { data: { name: 'project-3B.doc', kind: 'doc', size: '266 KB' } }, + { data: { name: 'project-3C.doc', kind: 'doc', size: '0' } }, + ], + }, + { data: { name: 'project-4.docx', kind: 'docx', size: '900 KB' } }, + ], + }, + { + data: { name: 'Reports', kind: 'dir', size: '400 KB', items: 2 }, + children: [ + { + data: { name: 'Report 1', kind: 'dir', size: '100 KB', items: 1 }, + children: [ + { data: { name: 'report-1.doc', kind: 'doc', size: '100 KB' } }, + ], + }, + { + data: { name: 'Report 2', kind: 'dir', size: '300 KB', items: 2 }, + children: [ + { data: { name: 'report-2.doc', kind: 'doc', size: '290 KB' } }, + { data: { name: 'report-2-note.txt', kind: 'txt', size: '10 KB' } }, + ], + }, + ], + }, + { + data: { name: 'Other', kind: 'dir', size: '109 MB', items: 2 }, + children: [ + { data: { name: 'backup.bkp', kind: 'bkp', size: '107 MB' } }, + { data: { name: 'secret-note.txt', kind: 'txt', size: '2 MB' } }, + ], + }, + ]; +} diff --git a/src/playground/with-layout/tree-grid/tree-grid.module.ts b/src/playground/with-layout/tree-grid/tree-grid.module.ts new file mode 100644 index 0000000000..f7839cf52a --- /dev/null +++ b/src/playground/with-layout/tree-grid/tree-grid.module.ts @@ -0,0 +1,34 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NbCardModule, NbInputModule, NbTreeGridModule } from '@nebular/theme'; + +import { TreeGridShowcaseComponent } from './tree-grid-showcase.component'; +import { TreeGridRoutingModule } from './tree-grid-routing.module'; +import { TreeGridSortableComponent } from './tree-grid-sortable.component'; +import { TreeGridFilterableComponent } from './tree-grid-filterable.component'; +import { TreeGridBasicComponent } from './tree-grid-basic.component'; +import { TreeGridResponsiveComponent } from './tree-grid-responsive.component'; +import { FsIconComponent } from './components/fs-icon.component'; +import { TreeGridCustomIconsComponent } from './tree-grid-custom-icons.component'; +import { TreeGridDisableClickToggleComponent } from './tree-grid-disable-click-toggle.component'; + +@NgModule({ + imports: [ CommonModule, NbTreeGridModule, TreeGridRoutingModule, NbCardModule, NbInputModule ], + declarations: [ + FsIconComponent, + TreeGridShowcaseComponent, + TreeGridSortableComponent, + TreeGridFilterableComponent, + TreeGridBasicComponent, + TreeGridResponsiveComponent, + TreeGridCustomIconsComponent, + TreeGridDisableClickToggleComponent, + ], +}) +export class TreeGridModule {} diff --git a/src/playground/with-layout/with-layout-routing.module.ts b/src/playground/with-layout/with-layout-routing.module.ts index 27e5645239..71edc66665 100644 --- a/src/playground/with-layout/with-layout-routing.module.ts +++ b/src/playground/with-layout/with-layout-routing.module.ts @@ -149,6 +149,10 @@ const routes: Route[] = [ path: 'smart-home', loadChildren: './smart-home/app.module#AppModule', }, + { + path: 'tree-grid', + loadChildren: './tree-grid/tree-grid.module#TreeGridModule', + }, ], }, ];