From: Stephan Müller Date: Thu, 1 Feb 2018 15:00:52 +0000 (+0100) Subject: mgr/dashboard_v2: Add table component X-Git-Tag: v13.0.2~84^2~99 X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=41c50f4c5257797b9fa8e488952eca2d63e980f4;p=ceph.git mgr/dashboard_v2: Add table component The current implementation isn't finished yet, but it's a start. The ngx-datatable is used as the core of the table component. You can use the table to show data in a table. You can search, paginate, sort, refresh the table contents. Multi selection is possible, the details of the selected items will be given to the specified detail component. What will be fixed soon? * Enable the usage of buttons in the table header * Enable details inline not beneath the table * Pagination to use a input field to switch pages * The columns to show can be checked and predefined * The selection made by the user will be saved in the local storage Signed-off-by: Stephan Müller Signed-off-by: Tiago Melo --- diff --git a/src/pybind/mgr/dashboard_v2/frontend/package.json b/src/pybind/mgr/dashboard_v2/frontend/package.json index 35fcc2d0d339..632ea6f85b1b 100644 --- a/src/pybind/mgr/dashboard_v2/frontend/package.json +++ b/src/pybind/mgr/dashboard_v2/frontend/package.json @@ -22,6 +22,7 @@ "@angular/platform-browser-dynamic": "^5.0.0", "@angular/router": "^5.0.0", "awesome-bootstrap-checkbox": "0.3.7", + "@swimlane/ngx-datatable": "^11.1.7", "bootstrap": "^3.3.7", "core-js": "^2.4.1", "font-awesome": "4.7.0", diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/components.module.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/components.module.ts new file mode 100644 index 000000000000..7baba7a53371 --- /dev/null +++ b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/components.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TableComponent } from './table/table.component'; +import { NgxDatatableModule } from '@swimlane/ngx-datatable'; +import { TableDetailsDirective } from './table/table-details.directive'; +import {FormsModule} from '@angular/forms'; + +@NgModule({ + entryComponents: [], + imports: [CommonModule, NgxDatatableModule, FormsModule], + declarations: [TableComponent, TableDetailsDirective], + exports: [TableComponent, NgxDatatableModule] +}) +export class ComponentsModule {} diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table-details.directive.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table-details.directive.spec.ts new file mode 100644 index 000000000000..b3e26843cbac --- /dev/null +++ b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table-details.directive.spec.ts @@ -0,0 +1,8 @@ +import { TableDetailsDirective } from './table-details.directive'; + +describe('TableDetailsDirective', () => { + it('should create an instance', () => { + const directive = new TableDetailsDirective(null); + expect(directive).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table-details.directive.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table-details.directive.ts new file mode 100644 index 000000000000..d3b226353db4 --- /dev/null +++ b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table-details.directive.ts @@ -0,0 +1,11 @@ +import {Directive, Input, ViewContainerRef} from '@angular/core'; + +@Directive({ + selector: '[cdTableDetails]' +}) +export class TableDetailsDirective { + @Input() selected?: any[]; + + constructor(public viewContainerRef: ViewContainerRef) { } + +} diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.html b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.html new file mode 100644 index 000000000000..aec7a2599717 --- /dev/null +++ b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.html @@ -0,0 +1,68 @@ +
+
+ +
+ +
+ + + +
+ + + + + + + +
+ + + +
+ +
+ + + +
+ + + +
+ +
+ + + + + +
+ diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.scss b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.scss new file mode 100644 index 000000000000..6cce9423b4e4 --- /dev/null +++ b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.scss @@ -0,0 +1,71 @@ +.dataTables_wrapper { + margin-bottom: 25px; + .separator { + height: 30px; + border-left: 1px solid rgba(0,0,0,.09); + padding-left: 5px; + margin-left: 5px; + display: inline-block; + vertical-align: middle; + } + .widget-toolbar { + display: inline-block; + float: right; + width: auto; + height: 30px; + line-height: 28px; + position: relative; + border-left: 1px solid rgba(0,0,0,.09); + cursor: pointer; + padding: 0 8px; + text-align: center; + } + .dropdown-menu { + white-space: nowrap; + & > li { + cursor: pointer; + & > label { + width: 100%; + margin-bottom: 0; + padding-left: 20px; + padding-right: 20px; + cursor: pointer; + &:hover { + background-color: #f5f5f5; + } + & > input { + cursor: pointer; + } + } + } + } + th.oadatatablecheckbox { + width: 16px; + } +} +.dataTables_header { + background-color: #f6f6f6; + border: 1px solid #d1d1d1; + border-bottom: none; + padding: 5px; + position: relative; + .oadatatableactions { + display: inline-block; + } + .input-group { + float: right; + border-left: 1px solid rgba(0,0,0,.09); + padding-left: 8px; + width: 40%; + max-width: 350px; + .form-control { + height: 30px; + } + .clear-input { + height: 30px; + i { + vertical-align: text-top; + } + } + } +} diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.spec.ts new file mode 100644 index 000000000000..c063b0165789 --- /dev/null +++ b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.spec.ts @@ -0,0 +1,87 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TableComponent } from './table.component'; +import {NgxDatatableModule, TableColumn} from '@swimlane/ngx-datatable'; +import {FormsModule} from '@angular/forms'; + +describe('TableComponent', () => { + let component: TableComponent; + let fixture: ComponentFixture; + const columns: TableColumn[] = []; + const createFakeData = (n) => { + const data = []; + for (let i = 0; i < n; i++) { + data.push({ + a: i, + b: i * i, + c: -(i % 10) + }); + } + return data; + }; + + beforeEach( + async(() => { + TestBed.configureTestingModule({ + declarations: [TableComponent], + imports: [NgxDatatableModule, FormsModule] + }).compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(TableComponent); + component = fixture.componentInstance; + }); + + beforeEach(() => { + component.data = createFakeData(100); + component.useData(); + component.columns = [ + {prop: 'a'}, + {prop: 'b'}, + {prop: 'c'} + ]; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have rows', () => { + expect(component.data.length).toBe(100); + expect(component.rows.length).toBe(component.data.length); + }); + + it('should have an int in setLimit parsing a string', () => { + expect(component.limit).toBe(10); + expect(component.limit).toEqual(jasmine.any(Number)); + + const e = {target: {value: '1'}}; + component.setLimit(e); + expect(component.limit).toBe(1); + expect(component.limit).toEqual(jasmine.any(Number)); + e.target.value = '-20'; + component.setLimit(e); + expect(component.limit).toBe(1); + }); + + it('should search for 13', () => { + component.search = '13'; + expect(component.rows.length).toBe(100); + component.updateFilter(true); + expect(component.rows[0].a).toBe(13); + expect(component.rows[1].b).toBe(1369); + expect(component.rows[2].b).toBe(3136); + expect(component.rows.length).toBe(3); + }); + + it('should restore full table after search', () => { + component.search = '13'; + expect(component.rows.length).toBe(100); + component.updateFilter(true); + expect(component.rows.length).toBe(3); + component.updateFilter(); + expect(component.rows.length).toBe(100); + }); +}); diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.ts new file mode 100644 index 000000000000..6751ac9a015d --- /dev/null +++ b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.ts @@ -0,0 +1,98 @@ +import { + Component, EventEmitter, OnInit, Input, Output, ViewChild, OnChanges, ComponentFactoryResolver, Type +} from '@angular/core'; +import {DatatableComponent, TableColumn} from '@swimlane/ngx-datatable'; +import {TableDetailsDirective} from './table-details.directive'; + +@Component({ + selector: 'cd-table', + templateUrl: './table.component.html', + styleUrls: ['./table.component.scss'] +}) +export class TableComponent implements OnInit, OnChanges { + @ViewChild(DatatableComponent) table: DatatableComponent; + @ViewChild(TableDetailsDirective) detailTemplate: TableDetailsDirective; + + @Input() data: any[]; // This is the array with the items to be shown + @Input() columns: TableColumn[]; // each item -> { prop: 'attribute name', name: 'display name' } + @Input() detailsComponent?: string; // name of the component fe 'TableDetailsComponent' + @Input() header? = true; + + @Output() fetchData = new EventEmitter(); // Should be the function that will update the input data + + selectable: String = undefined; + search = ''; + rows = []; + selected = []; + paginationClasses = { + pagerLeftArrow: 'i fa fa-angle-double-left', + pagerRightArrow: 'i fa fa-angle-double-right', + pagerPrevious: 'i fa fa-angle-left', + pagerNext: 'i fa fa-angle-right' + }; + limit = 10; + + constructor(private componentFactoryResolver: ComponentFactoryResolver) {} + + ngOnInit() { + this.reloadData(); + if (this.detailsComponent) { + this.selectable = 'multi'; + } + } + + ngOnChanges(changes) { + this.useData(); + } + + setLimit(e) { + const value = parseInt(e.target.value, 10); + if (value > 0) { + this.limit = value; + } + } + + reloadData() { + this.fetchData.emit(); + } + + useData() { + this.rows = [...this.data]; + } + + toggleExpandRow() { + if (this.selected.length > 0) { + this.table.rowDetail.toggleExpandRow(this.selected[0]); + } + } + + updateDetailView() { + if (!this.detailsComponent) { + return; + } + const factories = Array.from(this.componentFactoryResolver['_factories'].keys()); + const factoryClass = >factories.find((x: any) => x.name === this.detailsComponent); + this.detailTemplate.viewContainerRef.clear(); + const cmpRef = this.detailTemplate.viewContainerRef.createComponent( + this.componentFactoryResolver.resolveComponentFactory(factoryClass) + ); + cmpRef.instance.selected = this.selected; + } + + updateFilter(event?) { + if (!event) { + this.search = ''; + } + const val = this.search.toLowerCase(); + const columns = this.columns; + // update the rows + this.rows = this.data.filter(function (d) { + return columns.filter((c) => { + return (typeof d[c.prop] === 'string' || typeof d[c.prop] === 'number') + && (d[c.prop] + '').toLowerCase().indexOf(val) !== -1; + }).length > 0; + }); + // Whenever the filter changes, always go back to the first page + this.table.offset = 0; + } +} diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/shared.module.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/shared.module.ts index 5c2e80731b4b..083e658c6022 100644 --- a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/shared.module.ts +++ b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/shared.module.ts @@ -5,11 +5,13 @@ import { AuthStorageService } from './services/auth-storage.service'; import { AuthGuardService } from './services/auth-guard.service'; import { PipesModule } from './pipes/pipes.module'; import { HostService } from './services/host.service'; +import { ComponentsModule } from './components/components.module'; @NgModule({ imports: [ CommonModule, - PipesModule + PipesModule, + ComponentsModule ], declarations: [], providers: [ diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/defaults.scss b/src/pybind/mgr/dashboard_v2/frontend/src/defaults.scss index 3000628b0296..fd0d6d44b6d6 100644 --- a/src/pybind/mgr/dashboard_v2/frontend/src/defaults.scss +++ b/src/pybind/mgr/dashboard_v2/frontend/src/defaults.scss @@ -1 +1,10 @@ $oa-color-blue: #288cea; +$oa-color-light-blue: #afd9ee; +$bg-color-light-blue: #d9edf7; +$border-color: 1px solid #d1d1d1; +@mixin table-cell { + padding: 5px; + border: none; + border-left: $border-color; + border-bottom: $border-color; +} diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/openattic-theme.scss b/src/pybind/mgr/dashboard_v2/frontend/src/openattic-theme.scss index 54e1d90acdcd..1e9de270941f 100755 --- a/src/pybind/mgr/dashboard_v2/frontend/src/openattic-theme.scss +++ b/src/pybind/mgr/dashboard_v2/frontend/src/openattic-theme.scss @@ -605,7 +605,7 @@ ul.task-queue-pagination { margin-bottom: 0; } .panel-openattic { - border: 1px solid #d1d1d1; + border: $border-color; border-top: 0; border-radius: 0; } @@ -622,131 +622,113 @@ ul.task-queue-pagination { } .panel-openattic>.panel-body { background: #ffffff; - border-top: 1px solid #d1d1d1; + border-top: $border-color; padding: 10px 15px; } .panel-openattic>.panel-footer { background: #ffffff; - border-top: 1px solid #d1d1d1; + border-top: $border-color; } -/* Table */ -table.datatable { +/* Table + * Has to be here because the table component uses ngx-datatable component in + * the template and styles are not inherited by nested components. + */ +.ngx-datatable.oadatatable { + border: $border-color; margin-bottom: 0; - max-width: none!important -} -table.dataTable thead .sorting_asc, -table.dataTable thead .sorting_desc { - color: $oa-color-blue; -} -table.dataTable thead .sorting, -table.dataTable thead .sorting_asc, -table.dataTable thead .sorting_desc { - cursor: pointer; -} -table.datatable thead .sorting:after, -table.datatable thead .sorting_asc:after, -table.datatable thead .sorting_desc:after { - font-family: FontAwesome; - font-weight: 400; - height: 9px; - left: 10px; - line-height: 12px; - position: relative; - vertical-align: baseline; - width: 12px; -} -table.datatable thead .sorting:after { - content: "\f0dc"; -} -table.datatable thead .sorting_asc:after { - content: "\f160"; -} -table.datatable thead .sorting_desc:after { - content: "\f161"; -} -.table>tbody>tr>td, -.table>tbody>tr>th, -.table>tfoot>tr>td, -.table>tfoot>tr>th, -.table>thead>tr>td, -.table>thead>tr>th { - padding: 5px; -} -.table>tbody>tr>td>input, -.table>tbody>tr>th>input, -.table>tfoot>tr>td>input, -.table>tfoot>tr>th>input, -.table>thead>tr>td>input, -.table>thead>tr>th>input { - display: block; -} -.table>thead { - background-clip: padding-box; - background-color: #f9f9f9; - background-image: -webkit-linear-gradient(top,#fafafa 0,#ededed 100%); - background-image: -o-linear-gradient(top,#fafafa 0,#ededed 100%); - background-image: linear-gradient(to bottom,#fafafa 0,#ededed 100%); - background-repeat: repeat-x; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffafafa', endColorstr='#ffededed', GradientType=0); -} - -.table.header-text-center>thead>tr>th { - text-align: center; -} - -.table-bordered { - border: none; - border-top: 1px solid #d1d1d1; - border-right: 1px solid #d1d1d1; -} - -.table-bordered>tbody>tr>td, -.table-bordered>tbody>tr>th, -.table-bordered>tfoot>tr>td, -.table-bordered>tfoot>tr>th, -.table-bordered>thead>tr>td, -.table-bordered>thead>tr>th { - border: none; - border-left: 1px solid #d1d1d1; - border-bottom: 1px solid #d1d1d1; -} -.table-striped>tbody>tr:nth-of-type(odd) { - background-color: #ffffff; -} -.table-striped>tbody>tr:nth-of-type(even) { - background-color: #f6f6f6; -} -.table-responsive { - overflow-x: auto; - margin-bottom: 0; - min-height: .01%; -} - -.table-no-background>thead { - background: none; -} -.table-no-background>thead>tr>th { - border-bottom: 1px solid #ddd; -} -.table-no-background>tbody>tr>td { - height: 50px; - vertical-align: middle; - border-top: 0px; - border-bottom: 1px solid #ddd; -} - -.table-transparent>thead { - background: none; -} -.table-transparent>thead>tr>th { - border-bottom: 0px; -} -.table-transparent>tbody>tr>td { - height: 50px; - vertical-align: middle; - border-top: 0px; - border-bottom: 0px; + max-width: none!important; + .datatable-header { + background-clip: padding-box; + background-color: #f9f9f9; + background-image: -webkit-linear-gradient(top,#fafafa 0,#ededed 100%); + background-image: -o-linear-gradient(top,#fafafa 0,#ededed 100%); + background-image: linear-gradient(to bottom,#fafafa 0,#ededed 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffafafa', endColorstr='#ffededed', GradientType=0); + .sort-asc, .sort-desc { + color: $oa-color-blue; + } + .datatable-header-cell{ + @include table-cell; + text-align: center; + .datatable-header-cell-label { + &:after { + font-family: FontAwesome; + font-weight: 400; + height: 9px; + left: 10px; + line-height: 12px; + position: relative; + vertical-align: baseline; + width: 12px; + } + } + &.sortable { + .datatable-header-cell-label:after { + content: " \f0dc"; + } + &.sort-active { + &.sort-asc .datatable-header-cell-label:after { + content: " \f160"; + } + &.sort-desc .datatable-header-cell-label:after { + content: " \f161"; + } + } + } + &:first-child { + border-left: none; + } + } + } + .datatable-body { + .datatable-body-row { + &.datatable-row-even { + background-color: #ffffff; + } + &.datatable-row-odd { + background-color: #f6f6f6; + } + &:hover { + background-color: $oa-color-light-blue; + } + &.active, &.active:hover { + background-color: $bg-color-light-blue; + } + .datatable-body-cell{ + @include table-cell; + &:first-child { + border-left: none; + } + .datatable-body-cell-label { + display: block; + } + } + } + } + .datatable-footer { + .selected-count .page-count { + padding-left: 5px; + } + .datatable-pager .pager { + margin-right: 5px; + .pages { + & > a, & > span { + display: inline-block; + padding: 5px 10px; + margin-bottom: 5px; + border: none; + } + a:hover { + background-color: $oa-color-light-blue; + } + &.active > a { + background-color: $bg-color-light-blue; + } + } + } + } } /* Typo */ @@ -812,157 +794,6 @@ h6{ color: $oa-color-blue; } -/* Datatables */ -.dataTables_wrapper { - margin-bottom: 25px; -} -.dataTables_wrapper .separator { - height: 30px; - border-left: 1px solid rgba(0,0,0,.09); - padding-left: 5px; - margin-left: 5px; - display: inline-block; - vertical-align: middle; -} -.dataTables_wrapper .widget-toolbar { - display: inline-block; - float: right; - width: auto; - height: 30px; - line-height: 28px; - position: relative; - border-left: 1px solid rgba(0,0,0,.09); - cursor: pointer; - padding: 0 8px; - text-align: center; -} -.dataTables_wrapper .dropdown-menu { - white-space: nowrap; -} -.dataTables_wrapper .dropdown-menu>li { - cursor: pointer; -} -.dataTables_wrapper .dropdown-menu>li>label { - width: 100%; - margin-bottom: 0; - padding-left: 20px; - padding-right: 20px; - cursor: pointer; -} -.dataTables_wrapper .dropdown-menu>li>label:hover { - background-color: #f5f5f5; -} -.dataTables_wrapper .dropdown-menu>li>label>input { - cursor: pointer; -} -.dataTables_wrapper th.oadatatablecheckbox { - width: 16px; -} -.dataTables_header { - background-color: #f6f6f6; - border: 1px solid #d1d1d1; - border-bottom: none; - padding: 5px; - position: relative; -} -.dataTables_header .oadatatableactions { - display: inline-block; -} -.dataTables_header .input-group { - float: right; - border-left: 1px solid rgba(0,0,0,.09); - padding-left: 8px; - width: 40%; - max-width: 350px; -} -.dataTables_header .input-group .input-group-addon { -} -.dataTables_header .input-group .form-control { - height: 30px; -} -.dataTables_header .input-group .clear-input { - height: 30px; -} -.dataTables_header .input-group .clear-input i { - vertical-align: text-top; -} -.dataTables_no-match { - border: 1px solid #d1d1d1; - padding: 10px 0; - text-align: center; - font-weight: bold; - font-style: italic; -} -.dataTables_content .progress { - max-height: 16px; -} -.dataTables_content .progress span { - line-height: 16px; -} -.dataTables_footer { - background-color: #ffffff; - border: 1px solid #d1d1d1; - border-top: none; - padding: 0; - overflow: hidden; -} -.dataTables_footer .dataTables_info { - float: left; - padding-top: 6px; - padding-left: 5px; - font-style: italic; -} -.dataTables_paginate { - background: #fafafa; - float: right; - margin: 0; -} -.dataTables_paginate .pagination { - float: left; - margin: 0; -} -.dataTables_paginate .pagination.paginate-input { - line-height: 1em; - padding: 0.3em 0.5em; -} -.dataTables_paginate .pagination>li.disabled>span { - background: #f5f5f5; - border-left-color: #ececec; - border-right-color: #ececec; -} -.dataTables_paginate .pagination>li.disabled>span, -.dataTables_paginate .pagination>li>span:focus, -.dataTables_paginate .pagination>li>span:hover { - filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); -} -.dataTables_paginate .pagination>li>span { - border-color: #fff #e1e1e1 #f4f4f4; - border-width: 0 1px; - font-size: 1.2em; - font-weight: 400; - padding: 4px; - text-align: center; - width: 31px; -} -.dataTables_paginate .pagination .last>span { - border-right: 0; -} -.oadatatable div.overlay, div.oa-overlay { - position: absolute; - top: 0; - left: 0; - z-index: 10; - height: 100%; - width: 100%; - background-color: rgba(30, 30, 30, 0.2); -} -.oadatatable div.overlay-content, div.oa-overlay-content { - margin: 200px auto; - width: 50px; - height: 50px; - background-color: rgba(30, 30, 30, 0); -} - /* Feedback */ #feedback .feedback-button { position: fixed;