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 <smueller@suse.com>
Signed-off-by: Tiago Melo <tmelo@suse.com>
This commit is contained in:
Stephan Müller 2018-02-01 16:00:52 +01:00 committed by Ricardo Dias
parent 06066f0dda
commit 41c50f4c52
No known key found for this signature in database
GPG Key ID: 74390C579BD37B68
11 changed files with 471 additions and 271 deletions

View File

@ -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",

View File

@ -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 {}

View File

@ -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();
});
});

View File

@ -0,0 +1,11 @@
import {Directive, Input, ViewContainerRef} from '@angular/core';
@Directive({
selector: '[cdTableDetails]'
})
export class TableDetailsDirective {
@Input() selected?: any[];
constructor(public viewContainerRef: ViewContainerRef) { }
}

View File

@ -0,0 +1,68 @@
<div class="dataTables_wrapper">
<div class="dataTables_header clearfix"
*ngIf="header">
<!-- actions -->
<div class="oadatatableactions">
<ng-content select="table-actions"></ng-content>
</div>
<!-- end actions -->
<!-- search -->
<div class="input-group">
<span class="input-group-addon">
<i class="glyphicon glyphicon-search"></i>
</span>
<input
class="form-control"
type="text"
[(ngModel)]="search"
(keyup)='updateFilter($event)'>
<span class="input-group-btn">
<button type="button"
class="btn btn-default clear-input tc_clearInputBtn"
(click)="updateFilter()">
<i class="icon-prepend fa fa-remove"></i>
</button>
</span>
</div>
<!-- end search -->
<!-- pagination limit -->
<div class="dataTables_length widget-toolbar">
<input type="number"
min="1"
max="9999"
[value]="limit"
(click)="setLimit($event)"
(keyup)="setLimit($event)"
(blur)="setLimit($event)">
</div>
<!-- end pagination limit-->
<!-- refresh button -->
<div class="widget-toolbar tc_refreshBtn">
<a (click)="reloadData()">
<i class="fa fa-lg fa-refresh"></i>
</a>
</div>
<!-- end refresh button -->
</div>
<ngx-datatable #table
class="bootstrap oadatatable"
[cssClasses]="paginationClasses"
[selectionType]="selectable"
[selected]="selected"
(select)="toggleExpandRow($event)"
[columns]="columns"
[columnMode]="'force'"
[rows]="rows"
[footerHeight]="'auto'"
[limit]="limit"
[loadingIndicator]="true"
[rowHeight]="'auto'">
<!-- Row Detail Template -->
<ngx-datatable-row-detail (toggle)="updateDetailView($event)">
</ngx-datatable-row-detail>
</ngx-datatable>
</div>
<ng-template cdTableDetails></ng-template>

View File

@ -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;
}
}
}
}

View File

@ -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<TableComponent>;
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);
});
});

View File

@ -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 = <Type<any>>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;
}
}

View File

@ -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: [

View File

@ -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;
}

View File

@ -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;